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
52 changes: 52 additions & 0 deletions README-issue-86.md
Original file line number Diff line number Diff line change
@@ -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
```
27 changes: 27 additions & 0 deletions backend/app/background/noop_worker.py
Original file line number Diff line number Diff line change
@@ -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()
8 changes: 5 additions & 3 deletions backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down
8 changes: 8 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 4 additions & 3 deletions backend/app/routers/interview.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
AppliedInterviewResponse,
CustomInterviewBasicResponse,
CustomInterviewCreate,
CustomInterviewReadResponse,
CustomInterviewResponse,
)
from app.utils.authorization import get_current_user, is_organization
Expand Down Expand Up @@ -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.
"""
Expand All @@ -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)
22 changes: 18 additions & 4 deletions backend/app/schemas/interview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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] = []

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions backend/app/utils/default_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}'")
Binary file added backend/dev.db
Binary file not shown.
Loading