feat(placements): v0.11 P5 learner placement visibility (REQ-11-005) [4/10] — wave 5-1: GET /applications/mine (G-322) + GET /applications/{id} (D-104 status + history + placement) on the D-043 stack
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled

This commit is contained in:
2026-10-21 04:41:08 +00:00
parent e1ef420b2d
commit a62acf8241
+118 -1
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import time
from collections import deque
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field, ValidationError
@@ -33,7 +34,15 @@ from ..marketplace.resume import (
parse_resume,
)
from ..marketplace.search import FacetFilters, rank_postings
from ..placements.base import InvalidTransition
from ..placements.base import (
InvalidTransition,
LearnerApplicationStatusResponse,
LearnerApplicationSummary,
MineApplicationResponse,
PlacementEventRead,
PlacementRead,
)
from ..placements.store import PlacementRow
from .deps import get_embedding_provider, get_marketplace_store, get_provider, get_settings
from .identity import require_verified_age
@@ -455,6 +464,114 @@ async def withdraw_application(
)
# -- v0.11 P5: the learner status reads (D-104/G-322, REQ-11-005) -------------------
def _placement_read(row: PlacementRow) -> PlacementRead:
"""The PlacementRow → PlacementRead conversion (the placements_admin
helper mirrored — each route file owns its readers; start_date is
the ISO DATE string on the row, a date on the wire, D-103)."""
return PlacementRead(
id=row.id,
learner_id=row.learner_id,
employer_id=row.employer_id,
posting_id=row.posting_id,
application_id=row.application_id,
role_title=row.role_title,
start_date=date.fromisoformat(row.start_date),
status=row.status, # type: ignore[arg-type]
created_at=row.created_at or _now(),
decided_at=row.decided_at or _now(),
)
def _now():
from datetime import UTC, datetime
return datetime.now(UTC)
@router.get("/applications/mine", response_model=MineApplicationResponse)
async def mine_application(
request: Request,
posting_id: str = Query(min_length=1),
learner_id: str = Query(min_length=1),
store=Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
) -> MineApplicationResponse:
"""G-322 (binding): the mine point-read — the status panel's
application DISCOVERY on revisit (the panel NEVER depends on
client-persisted application_ids). Gate composition: G-5 allowlist
→ verified 18+ (reads are free — the apply-rate window is the
WRITE budget, A-408's distinct-action rule; no new throttle knob,
D-088 untouched); no row → the honest 404 (the surface renders the
apply CTA)."""
_enforce_allowlist(learner_id, settings)
await require_verified_age(18, learner_id, _identity_store(request))
application = store.get_application_by_posting_learner(posting_id, learner_id)
if application is None:
raise HTTPException(
status_code=404,
detail=f"no application by this learner on posting {posting_id!r}",
)
return MineApplicationResponse(
application_id=application.id,
status=application.status, # type: ignore[arg-type]
)
@router.get(
"/applications/{application_id}",
response_model=LearnerApplicationStatusResponse,
)
async def learner_application_status(
application_id: str,
request: Request,
learner_id: str = Query(min_length=1),
store=Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
) -> LearnerApplicationStatusResponse:
"""D-104 (REQ-11-005): the learner's-own outcome read — current
status + the FULL id-ordered timestamped history for THIS
application + the placement record when hired (one read, one
truth source). Same gates as the mine-read (reads are free);
foreign learner_id → 403 (the scoped query never renders another
learner's events); unknown → 404."""
_enforce_allowlist(learner_id, settings)
await require_verified_age(18, learner_id, _identity_store(request))
application = store.get_application(application_id)
if application is None:
raise HTTPException(
status_code=404, detail=f"no application with id {application_id!r}"
)
if application.learner_id != learner_id:
raise HTTPException(
status_code=403,
detail="this application belongs to another learner",
)
from .deps import get_placement_store # local import — the territory rule
placements = get_placement_store(request)
events = [
PlacementEventRead.model_validate(event, from_attributes=True)
for event in placements.get_application_events(application_id)
]
row = placements.get_placement_by_application(application_id)
return LearnerApplicationStatusResponse(
application=LearnerApplicationSummary(
id=application.id,
posting_id=application.posting_id,
learner_id=application.learner_id,
status=application.status, # type: ignore[arg-type]
created_at=application.created_at,
),
events=events,
placement=_placement_read(row) if row is not None else None,
)
# -- P4-B: resume upload + confirm (REQ-6-005, D-054, A-410) --------------------