From e263a3a64660a67101195879badd85d9ac70469d Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai <157192462+saurabhhhcodes@users.noreply.github.com> Date: Fri, 29 May 2026 01:29:09 +0530 Subject: [PATCH 1/3] Add Cloudinary storage provider --- backend/README.md | 13 +- backend/app/config.py | 6 + backend/app/models/dsa_question.py | 8 +- backend/app/utils/cloudinary_provider.py | 90 +++++++++++ backend/app/utils/default_providers.py | 5 + backend/pyproject.toml | 1 + backend/tests/conftest.py | 1 + .../test_utils/test_cloudinary_provider.py | 140 ++++++++++++++++++ backend/uv.lock | 16 ++ 9 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 backend/app/utils/cloudinary_provider.py create mode 100644 backend/tests/test_utils/test_cloudinary_provider.py diff --git a/backend/README.md b/backend/README.md index 587fb9a..74ec6dc 100644 --- a/backend/README.md +++ b/backend/README.md @@ -142,10 +142,19 @@ REDIS_URL=redis://localhost:6379/0 LLM_MODEL_NAME=groq/openai/gpt-oss-120b GROQ_API_KEY=your-groq-api-key +# Storage +STORAGE_PROVIDER=supabase + # Supabase Storage SUPABASE_URL=https://your-project.supabase.co SUPABASE_KEY=your-service-role-key SUPABASE_BUCKET_NAME=resumes + +# Cloudinary Storage (set STORAGE_PROVIDER=cloudinary) +CLOUDINARY_CLOUD_NAME=your-cloud-name +CLOUDINARY_API_KEY=your-api-key +CLOUDINARY_API_SECRET=your-api-secret +CLOUDINARY_FOLDER=resumes ``` All variables are defined and validated in `app/config.py`. Access them anywhere via the `settings` singleton: @@ -253,13 +262,13 @@ All major integrations follow the same pattern — abstractions in `app/interfac ``` app/interfaces/llm_provider.py → app/ai/lite_llm.py -app/interfaces/storage_provider.py → app/utils/supabase.py +app/interfaces/storage_provider.py → app/utils/supabase.py, app/utils/cloudinary_provider.py app/interfaces/hasher.py → app/utils/ (BcryptHasher) app/interfaces/encrypter.py → app/utils/ (JwtEncrypter) app/interfaces/base_agent.py → app/ai/resume_evaluator.py ``` -This makes it straightforward to swap providers (e.g., Groq → OpenAI, Supabase → S3) without touching business logic. +This makes it straightforward to swap providers (e.g., Groq → OpenAI, Supabase → Cloudinary) without touching business logic. ### Model Structure diff --git a/backend/app/config.py b/backend/app/config.py index dea15df..2e03ee1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -30,6 +30,12 @@ class Settings(BaseSettings): SUPABASE_KEY: str = "" SUPABASE_BUCKET_NAME: str = "resumes" + # Cloudinary + CLOUDINARY_CLOUD_NAME: str = "" + CLOUDINARY_API_KEY: str = "" + CLOUDINARY_API_SECRET: str = "" + CLOUDINARY_FOLDER: str = "resumes" + # Piston (code execution) PISTON_URL: str = "http://localhost:2000" diff --git a/backend/app/models/dsa_question.py b/backend/app/models/dsa_question.py index 38071bb..49ccdcf 100644 --- a/backend/app/models/dsa_question.py +++ b/backend/app/models/dsa_question.py @@ -1,11 +1,13 @@ from typing import Any -from sqlalchemy import Integer, String, Text +from sqlalchemy import JSON, Integer, String, Text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from app.models.base import BaseTable +JSON_TYPE = JSON().with_variant(JSONB, "postgresql") + class DsaQuestion(BaseTable): __tablename__ = "dsa_questions" @@ -16,8 +18,8 @@ class DsaQuestion(BaseTable): description: Mapped[str] = mapped_column(Text, nullable=False) # Each entry: {"stdin": str, "expected_stdout": str}. Multi-line values use "\n". - test_cases: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False) - sample_test_cases: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True) + test_cases: Mapped[list[dict[str, Any]]] = mapped_column(JSON_TYPE, nullable=False) + sample_test_cases: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON_TYPE, nullable=True) sample_solution: Mapped[str | None] = mapped_column(Text, nullable=True) time_limit_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=5000) diff --git a/backend/app/utils/cloudinary_provider.py b/backend/app/utils/cloudinary_provider.py new file mode 100644 index 0000000..0e9c1e3 --- /dev/null +++ b/backend/app/utils/cloudinary_provider.py @@ -0,0 +1,90 @@ +from asyncio import to_thread +from io import BytesIO + +import cloudinary +import cloudinary.uploader +import cloudinary.utils +import httpx + +from app.config import settings +from app.exceptions.storage import ( + StorageDeleteError, + StorageDownloadError, + StorageUploadError, +) +from app.interfaces.storage_proivder import StorageProviderInterface +from app.logger import get_logger + +logger = get_logger(__name__) + + +class CloudinaryStorageProvider(StorageProviderInterface): + def __init__( + self, + cloud_name: str = settings.CLOUDINARY_CLOUD_NAME, + api_key: str = settings.CLOUDINARY_API_KEY, + api_secret: str = settings.CLOUDINARY_API_SECRET, + folder: str = settings.CLOUDINARY_FOLDER, + ): + if not cloud_name or not api_key or not api_secret: + logger.warning("Cloudinary credentials are not configured. Storage operations may fail.") + + self.folder = folder.strip("/") + cloudinary.config( + cloud_name=cloud_name, + api_key=api_key, + api_secret=api_secret, + secure=True, + ) + + def _public_id(self, file_name: str) -> str: + return f"{self.folder}/{file_name}" if self.folder else file_name + + async def upload(self, file: bytes, file_name: str) -> str: + try: + result = await to_thread( + cloudinary.uploader.upload, + BytesIO(file), + public_id=self._public_id(file_name), + resource_type="raw", + overwrite=True, + ) + url = result.get("secure_url") or result.get("url") + if not url: + raise StorageUploadError("Cloudinary upload response did not include a URL") + return url + except StorageUploadError: + raise + except Exception as e: + logger.error("Cloudinary upload failed: %s", str(e), exc_info=True) + raise StorageUploadError(f"Failed to upload file to storage: {str(e)}") from e + + async def delete(self, file_name: str) -> None: + try: + result = await to_thread( + cloudinary.uploader.destroy, + self._public_id(file_name), + resource_type="raw", + ) + if result.get("result") not in {"ok", "not found"}: + raise StorageDeleteError(f"Cloudinary delete failed: {result}") + except StorageDeleteError: + raise + except Exception as e: + logger.error("Cloudinary delete failed: %s", str(e), exc_info=True) + raise StorageDeleteError(f"Failed to delete file from storage: {str(e)}") from e + + async def download(self, file_name: str) -> bytes: + try: + url, _ = cloudinary.utils.cloudinary_url( + self._public_id(file_name), + resource_type="raw", + secure=True, + ) + async with httpx.AsyncClient() as client: + response = await client.get(url) + response.raise_for_status() + return response.content + except Exception as e: + logger.error("Cloudinary download failed: %s", str(e), exc_info=True) + raise StorageDownloadError(f"Failed to download file from storage: {str(e)}") from e diff --git a/backend/app/utils/default_providers.py b/backend/app/utils/default_providers.py index 1e7fb7e..641025e 100644 --- a/backend/app/utils/default_providers.py +++ b/backend/app/utils/default_providers.py @@ -10,6 +10,11 @@ def default_storage_provider() -> StorageProviderInterface: return SupabaseStorageProvider() + if settings.STORAGE_PROVIDER == "cloudinary": + from app.utils.cloudinary_provider import CloudinaryStorageProvider + + return CloudinaryStorageProvider() + raise ValueError(f"Unknown storage provider: '{settings.STORAGE_PROVIDER}'") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 369060f..a637389 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "taskiq-redis>=0.5.0", "python-multipart>=0.0.27", "supabase>=2.30.0", + "cloudinary>=1.44.0", "authlib>=1.7.2", "httpx>=0.28.1", "itsdangerous>=2.2.0", diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d655435..a6880d8 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -48,6 +48,7 @@ async def create_tables() -> AsyncGenerator[None, None]: """Create all ORM tables once per test session, drop them afterwards.""" # Import all models so Base.metadata knows about them. import app.models.application # noqa: F401 + import app.models.dsa_question # noqa: F401 import app.models.interaction # noqa: F401 import app.models.interview # noqa: F401 import app.models.organization # noqa: F401 diff --git a/backend/tests/test_utils/test_cloudinary_provider.py b/backend/tests/test_utils/test_cloudinary_provider.py new file mode 100644 index 0000000..a96aa65 --- /dev/null +++ b/backend/tests/test_utils/test_cloudinary_provider.py @@ -0,0 +1,140 @@ +from typing import Any + +import pytest + +from app.exceptions.storage import StorageDeleteError, StorageDownloadError, StorageUploadError +from app.utils.cloudinary_provider import CloudinaryStorageProvider + + +class _FakeResponse: + def __init__(self, content: bytes = b"pdf-bytes", should_raise: bool = False): + self.content = content + self.should_raise = should_raise + + def raise_for_status(self) -> None: + if self.should_raise: + raise RuntimeError("download failed") + + +class _FakeAsyncClient: + def __init__(self, response: _FakeResponse): + self.response = response + self.requested_url = "" + + async def __aenter__(self) -> "_FakeAsyncClient": + return self + + async def __aexit__(self, *args: Any) -> None: + return None + + async def get(self, url: str) -> _FakeResponse: + self.requested_url = url + return self.response + + +@pytest.mark.asyncio +async def test_upload_stores_pdf_as_raw_asset(monkeypatch: pytest.MonkeyPatch) -> None: + calls: dict[str, Any] = {} + + def fake_upload(file: Any, **kwargs: Any) -> dict[str, str]: + calls["payload"] = file.read() + calls["kwargs"] = kwargs + return {"secure_url": "https://res.cloudinary.com/demo/raw/upload/resumes/resume.pdf"} + + monkeypatch.setattr("app.utils.cloudinary_provider.cloudinary.uploader.upload", fake_upload) + + provider = CloudinaryStorageProvider( + cloud_name="demo", + api_key="key", + api_secret="secret", + folder="resumes", + ) + + result = await provider.upload(b"%PDF", "resume.pdf") + + assert result == "https://res.cloudinary.com/demo/raw/upload/resumes/resume.pdf" + assert calls["payload"] == b"%PDF" + assert calls["kwargs"] == { + "public_id": "resumes/resume.pdf", + "resource_type": "raw", + "overwrite": True, + } + + +@pytest.mark.asyncio +async def test_upload_wraps_cloudinary_errors(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_upload(*_args: Any, **_kwargs: Any) -> dict[str, str]: + raise RuntimeError("bad credentials") + + monkeypatch.setattr("app.utils.cloudinary_provider.cloudinary.uploader.upload", fake_upload) + provider = CloudinaryStorageProvider("demo", "key", "secret") + + with pytest.raises(StorageUploadError): + await provider.upload(b"%PDF", "resume.pdf") + + +@pytest.mark.asyncio +async def test_delete_removes_raw_asset(monkeypatch: pytest.MonkeyPatch) -> None: + calls: dict[str, Any] = {} + + def fake_destroy(public_id: str, **kwargs: Any) -> dict[str, str]: + calls["public_id"] = public_id + calls["kwargs"] = kwargs + return {"result": "ok"} + + monkeypatch.setattr("app.utils.cloudinary_provider.cloudinary.uploader.destroy", fake_destroy) + provider = CloudinaryStorageProvider("demo", "key", "secret", folder="resumes") + + await provider.delete("resume.pdf") + + assert calls == { + "public_id": "resumes/resume.pdf", + "kwargs": {"resource_type": "raw"}, + } + + +@pytest.mark.asyncio +async def test_delete_wraps_failed_result(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_destroy(*_args: Any, **_kwargs: Any) -> dict[str, str]: + return {"result": "error"} + + monkeypatch.setattr("app.utils.cloudinary_provider.cloudinary.uploader.destroy", fake_destroy) + provider = CloudinaryStorageProvider("demo", "key", "secret") + + with pytest.raises(StorageDeleteError): + await provider.delete("resume.pdf") + + +@pytest.mark.asyncio +async def test_download_fetches_cloudinary_raw_url(monkeypatch: pytest.MonkeyPatch) -> None: + fake_client = _FakeAsyncClient(_FakeResponse(content=b"%PDF")) + + def fake_cloudinary_url(public_id: str, **kwargs: Any) -> tuple[str, dict[str, Any]]: + assert public_id == "resumes/resume.pdf" + assert kwargs == {"resource_type": "raw", "secure": True} + return "https://res.cloudinary.com/demo/raw/upload/resumes/resume.pdf", {} + + monkeypatch.setattr("app.utils.cloudinary_provider.cloudinary.utils.cloudinary_url", fake_cloudinary_url) + monkeypatch.setattr("app.utils.cloudinary_provider.httpx.AsyncClient", lambda: fake_client) + provider = CloudinaryStorageProvider("demo", "key", "secret", folder="resumes") + + result = await provider.download("resume.pdf") + + assert result == b"%PDF" + assert fake_client.requested_url == "https://res.cloudinary.com/demo/raw/upload/resumes/resume.pdf" + + +@pytest.mark.asyncio +async def test_download_wraps_http_errors(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "app.utils.cloudinary_provider.cloudinary.utils.cloudinary_url", + lambda *_args, **_kwargs: ("https://example.test/resume.pdf", {}), + ) + monkeypatch.setattr( + "app.utils.cloudinary_provider.httpx.AsyncClient", + lambda: _FakeAsyncClient(_FakeResponse(should_raise=True)), + ) + provider = CloudinaryStorageProvider("demo", "key", "secret") + + with pytest.raises(StorageDownloadError): + await provider.download("resume.pdf") diff --git a/backend/uv.lock b/backend/uv.lock index b24166d..f863f9b 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -255,6 +255,7 @@ dependencies = [ { name = "asyncpg" }, { name = "authlib" }, { name = "bcrypt" }, + { name = "cloudinary" }, { name = "fastapi" }, { name = "greenlet" }, { name = "groq" }, @@ -293,6 +294,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.31.0" }, { name = "authlib", specifier = ">=1.7.2" }, { name = "bcrypt", specifier = ">=4.0.0" }, + { name = "cloudinary", specifier = ">=1.44.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "greenlet", specifier = ">=3.4.0" }, { name = "groq", specifier = ">=0.30.0,<1" }, @@ -549,6 +551,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] +[[package]] +name = "cloudinary" +version = "1.44.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "six" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/1b/ae48dbbe3c2ff81b94816dd6645af2d609e135cea4fcd65b1a8a61672cea/cloudinary-1.44.2.tar.gz", hash = "sha256:bc9139c68f2ef6996ba62a5d0300e63c711d4f6564644c0c82aa31bf641ef3c7", size = 188389, upload-time = "2026-04-16T08:18:19.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/8b/0da00a22362c89ef53f9d64af29546d24aba8533ffdb7bbcb19631c7f636/cloudinary-1.44.2-py3-none-any.whl", hash = "sha256:6624d06014c33f50c0c9e740fde1dfe4579d03311025d8bb7183824b7443aa09", size = 147805, upload-time = "2026-04-16T08:18:18.84Z" }, +] + [[package]] name = "colorama" version = "0.4.6" From 3d3ccd71fe22879c9ae129b869a4712a1cc0d759 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai <157192462+saurabhhhcodes@users.noreply.github.com> Date: Fri, 29 May 2026 01:31:38 +0530 Subject: [PATCH 2/3] style: satisfy ruff format check --- backend/app/routers/application.py | 4 +--- backend/app/routers/interview.py | 2 -- backend/app/utils/cloudinary_provider.py | 4 +++- backend/tests/test_utils/test_cloudinary_provider.py | 8 ++++++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/app/routers/application.py b/backend/app/routers/application.py index 108faed..4341ada 100644 --- a/backend/app/routers/application.py +++ b/backend/app/routers/application.py @@ -130,9 +130,7 @@ async def shortlist_application( org.id, ) - app_result = await db.execute( - select(Application).where(Application.id == application_id) - ) + app_result = await db.execute(select(Application).where(Application.id == application_id)) application = app_result.scalar_one_or_none() if not application: diff --git a/backend/app/routers/interview.py b/backend/app/routers/interview.py index 5916fcf..186406c 100644 --- a/backend/app/routers/interview.py +++ b/backend/app/routers/interview.py @@ -135,7 +135,6 @@ async def get_applied_interviews( return applied_interviews - @router.post( "/seed-test", response_model=CustomInterviewResponse, @@ -202,7 +201,6 @@ async def seed_test_interview( @router.get("/{interview_id}", response_model=CustomInterviewResponse) - async def get_interview( interview_id: int, db: AsyncSession = Depends(get_db), diff --git a/backend/app/utils/cloudinary_provider.py b/backend/app/utils/cloudinary_provider.py index 0e9c1e3..893d904 100644 --- a/backend/app/utils/cloudinary_provider.py +++ b/backend/app/utils/cloudinary_provider.py @@ -27,7 +27,9 @@ def __init__( folder: str = settings.CLOUDINARY_FOLDER, ): if not cloud_name or not api_key or not api_secret: - logger.warning("Cloudinary credentials are not configured. Storage operations may fail.") + logger.warning( + "Cloudinary credentials are not configured. Storage operations may fail." + ) self.folder = folder.strip("/") cloudinary.config( diff --git a/backend/tests/test_utils/test_cloudinary_provider.py b/backend/tests/test_utils/test_cloudinary_provider.py index a96aa65..7100f93 100644 --- a/backend/tests/test_utils/test_cloudinary_provider.py +++ b/backend/tests/test_utils/test_cloudinary_provider.py @@ -114,14 +114,18 @@ def fake_cloudinary_url(public_id: str, **kwargs: Any) -> tuple[str, dict[str, A assert kwargs == {"resource_type": "raw", "secure": True} return "https://res.cloudinary.com/demo/raw/upload/resumes/resume.pdf", {} - monkeypatch.setattr("app.utils.cloudinary_provider.cloudinary.utils.cloudinary_url", fake_cloudinary_url) + monkeypatch.setattr( + "app.utils.cloudinary_provider.cloudinary.utils.cloudinary_url", fake_cloudinary_url + ) monkeypatch.setattr("app.utils.cloudinary_provider.httpx.AsyncClient", lambda: fake_client) provider = CloudinaryStorageProvider("demo", "key", "secret", folder="resumes") result = await provider.download("resume.pdf") assert result == b"%PDF" - assert fake_client.requested_url == "https://res.cloudinary.com/demo/raw/upload/resumes/resume.pdf" + assert ( + fake_client.requested_url == "https://res.cloudinary.com/demo/raw/upload/resumes/resume.pdf" + ) @pytest.mark.asyncio From 6c50736b500b5bd5848e5421cb97ec8265c74154 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai <157192462+saurabhhhcodes@users.noreply.github.com> Date: Fri, 29 May 2026 01:33:39 +0530 Subject: [PATCH 3/3] fix: type cloudinary upload url response --- backend/app/utils/cloudinary_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/utils/cloudinary_provider.py b/backend/app/utils/cloudinary_provider.py index 893d904..2108daa 100644 --- a/backend/app/utils/cloudinary_provider.py +++ b/backend/app/utils/cloudinary_provider.py @@ -52,7 +52,7 @@ async def upload(self, file: bytes, file_name: str) -> str: overwrite=True, ) url = result.get("secure_url") or result.get("url") - if not url: + if not isinstance(url, str) or not url: raise StorageUploadError("Cloudinary upload response did not include a URL") return url except StorageUploadError: