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
This commit is contained in:
@@ -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) --------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user