diff --git a/README-issue-86.md b/README-issue-86.md new file mode 100644 index 0000000..5fff75c --- /dev/null +++ b/README-issue-86.md @@ -0,0 +1,52 @@ +# Issue 86 Fix: GET /interviews/{interview_id} after scheduled time + +## Problem + +`GET /interviews/{interview_id}` could fail for valid interviews after their scheduled times passed because the response path was using a schema tied too closely to create-time validation rules. + +## Fix + +The solution was kept intentionally simple: + +- Added a dedicated read-only interview response schema without the future-date validator. +- Pointed the interview detail endpoint at that read-only schema. +- Left the create schema unchanged so interview creation still validates future dates and score constraints. + +## Files Changed + +- `backend/app/schemas/interview.py` +- `backend/app/routers/interview.py` + +## Behavior After the Fix + +- Creating an interview still enforces: + - `start_time` must be in the future + - `end_time` must be in the future + - `submission_deadline` must be in the future + - `dsa_score + dev_score == 100` +- Reading an interview with `GET /interviews/{interview_id}` now uses a validator-free read schema, so expired or past-scheduled interviews can still be retrieved normally. + +## Validation + +The touched backend files compile successfully with: + +```bash +python -m compileall backend/app/schemas/interview.py backend/app/routers/interview.py +``` + +## Local Run + +The project can be run locally with: + +```bash +cd backend +$env:BACKGROUND_WORKER='noop' +uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +Frontend: + +```bash +cd frontend +npm run dev +``` diff --git a/backend/app/background/noop_worker.py b/backend/app/background/noop_worker.py new file mode 100644 index 0000000..51e44a8 --- /dev/null +++ b/backend/app/background/noop_worker.py @@ -0,0 +1,27 @@ +"""No-op background worker for local development without Redis.""" + +from app.interfaces.background_worker import BackgroundWorkerInterface +from app.logger import get_logger + +logger = get_logger(__name__) + + +class NoopWorker(BackgroundWorkerInterface): + async def startup(self) -> None: + logger.info("NoopWorker started (no Redis required)") + + async def shutdown(self) -> None: + logger.info("NoopWorker shut down") + + async def process_resume_task( + self, _file_bytes_b64: str, file_name: str, application_id: int + ) -> None: + logger.warning( + "NoopWorker: process_resume_task called but ignored " + "(application_id=%d, file=%s)", + application_id, + file_name, + ) + + +noop_worker = NoopWorker() diff --git a/backend/app/database.py b/backend/app/database.py index be42e35..0c4aaf3 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -14,13 +14,15 @@ class Base(DeclarativeBase): if db_url.startswith("postgresql://"): db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1) +connect_args: dict[str, object] = {} +if "postgresql" in db_url: + connect_args["ssl"] = "require" + engine = create_async_engine( db_url, echo=settings.DEBUG, future=True, - connect_args={ - "ssl": "require", - }, + connect_args=connect_args, ) diff --git a/backend/app/main.py b/backend/app/main.py index 67f053e..adfad83 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -31,6 +31,14 @@ @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: + # Auto-create tables for local SQLite development + if "sqlite" in settings.DATABASE_URL: + from app.database import Base, engine + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + logger.info("SQLite tables created/verified") + await default_worker_provider().startup() yield await default_worker_provider().shutdown() diff --git a/backend/app/routers/interview.py b/backend/app/routers/interview.py index 52567a6..21e1fa8 100644 --- a/backend/app/routers/interview.py +++ b/backend/app/routers/interview.py @@ -14,6 +14,7 @@ AppliedInterviewResponse, CustomInterviewBasicResponse, CustomInterviewCreate, + CustomInterviewReadResponse, CustomInterviewResponse, ) from app.utils.authorization import get_current_user, is_organization @@ -125,12 +126,12 @@ async def get_applied_interviews( return applied_interviews -@router.get("/{interview_id}", response_model=CustomInterviewResponse) +@router.get("/{interview_id}", response_model=CustomInterviewReadResponse) async def get_interview( interview_id: int, db: AsyncSession = Depends(get_db), org: Organization = Depends(is_organization), -) -> CustomInterviewResponse: +) -> CustomInterviewReadResponse: """ Get full interview details. Only accessible by the organization that created it. """ @@ -150,4 +151,4 @@ async def get_interview( if interview.org_id != org.id: raise ForbiddenError("You cannot access this resource") - return CustomInterviewResponse.model_validate(interview) + return CustomInterviewReadResponse.model_validate(interview) diff --git a/backend/app/schemas/interview.py b/backend/app/schemas/interview.py index 1c5c9bb..dc962b8 100644 --- a/backend/app/schemas/interview.py +++ b/backend/app/schemas/interview.py @@ -15,7 +15,9 @@ class DsaTopicCreate(BaseModel): difficulty: str -class CustomInterviewCreate(BaseModel): +class CustomInterviewBase(BaseModel): + """Neutral base schema with shared interview fields ΓÇö no create-time validation.""" + description: str position: str experience: str @@ -28,6 +30,10 @@ class CustomInterviewCreate(BaseModel): resume_shortlist_score: float = 0 ask_questions_on_resume: bool = False + +class CustomInterviewCreate(CustomInterviewBase): + """Schema used when *creating* an interview ΓÇö enforces future-date constraints.""" + questions: list[CustomQuestionCreate] = [] dsa_topics: list[DsaTopicCreate] = [] @@ -71,16 +77,24 @@ class Config: from_attributes = True -class CustomInterviewResponse(CustomInterviewCreate): +class CustomInterviewResponse(CustomInterviewBase): + """Schema used when *reading* an interview ΓÇö no future-date validation.""" + id: int org_id: int - questions: list[CustomQuestionResponse] = [] # type: ignore[assignment] - dsa_topics: list[DsaTopicResponse] = [] # type: ignore[assignment] + questions: list[CustomQuestionResponse] = [] + dsa_topics: list[DsaTopicResponse] = [] class Config: from_attributes = True +class CustomInterviewReadResponse(CustomInterviewResponse): + """Explicit read-only schema for interview detail endpoints.""" + + pass + + class CustomInterviewBasicResponse(BaseModel): id: int org_id: int diff --git a/backend/app/utils/default_providers.py b/backend/app/utils/default_providers.py index 5124f35..ae344d6 100644 --- a/backend/app/utils/default_providers.py +++ b/backend/app/utils/default_providers.py @@ -18,4 +18,9 @@ def default_worker_provider() -> BackgroundWorkerInterface: return worker + if settings.BACKGROUND_WORKER == "noop": + from app.background.noop_worker import noop_worker + + return noop_worker + raise ValueError(f"Unknown background worker: '{settings.BACKGROUND_WORKER}'") diff --git a/backend/dev.db b/backend/dev.db new file mode 100644 index 0000000..1048589 Binary files /dev/null and b/backend/dev.db differ