Skip to content

Commit 1a3e5d6

Browse files
committed
Add school profile API and validation handler
1 parent 406afec commit 1a3e5d6

17 files changed

Lines changed: 647 additions & 22 deletions

File tree

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
College Exploration Platform is a full-stack decision-support product for helping prospective and admitted students discover, compare, rank, and justify college choices with transparent data and deterministic scoring.
44

5-
Status: V1.4 structured search API complete. Frontend pages, ranking logic, Redis, pgvector, and deployment are intentionally not implemented yet.
5+
Status: V1.5 school profile API complete. Frontend pages, ranking logic, Redis, pgvector, and deployment are intentionally not implemented yet.
66

77
## Project Thesis
88

@@ -109,6 +109,7 @@ Useful local URLs:
109109
- API health: `http://127.0.0.1:8000/health`
110110
- DB readiness: `http://127.0.0.1:8000/ready`
111111
- Structured search: `http://127.0.0.1:8000/schools/search`
112+
- School profile: `http://127.0.0.1:8000/schools/1`
112113
- OpenAPI docs: `http://127.0.0.1:8000/docs`
113114

114115
Example search request:
@@ -117,6 +118,16 @@ Example search request:
117118
curl "http://127.0.0.1:8000/schools/search?state=CA&min_net_price=15000&max_net_price=40000&sort=net_price&page=1&page_size=10"
118119
```
119120

121+
Example profile request:
122+
123+
```powershell
124+
curl "http://127.0.0.1:8000/schools/1"
125+
```
126+
127+
`GET /schools/{id}` composes a full profile from the core `schools` row plus academic, cost, outcome, and campus-life tables. The API keeps ranking placeholders such as `fit_score`, `category_scores`, reasons, tradeoffs, and `similar_schools` empty until those roadmap steps are implemented.
128+
129+
Missing data is treated as unknown. The API returns `null` for missing values, lists those fields in `data_fields_missing`, and includes a simple `data_confidence_score` based on profile completeness. It does not convert missing numbers to zero or infer school facts that are not in the database.
130+
120131
### Windows Install Troubleshooting
121132

122133
If installation fails with `Failed building wheel for pydantic-core`, `maturin failed`, or `link.exe not found`, pip is trying to compile native code locally. That usually means the venv is using an unsupported or too-new Python version, or pip has cached an incompatible artifact.
@@ -166,7 +177,7 @@ Expected future commands:
166177

167178
## Limitations
168179

169-
- `/health`, `/ready`, and `/schools/search` exist. Profile, saved-school, comparison, and ranking endpoints are not implemented yet.
180+
- `/health`, `/ready`, `/schools/search`, and `/schools/{id}` exist. Saved-school, comparison, and ranking endpoints are not implemented yet.
170181
- No UI pages, ranking engine, Redis cache, pgvector integration, or deployment exists yet.
171182
- No performance metrics are available.
172183
- Seed data is synthetic and intended for deterministic local development, not factual school reporting.

apps/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

apps/api/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

apps/api/api/routes/schools.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
from typing import Annotated
22

3-
from fastapi import APIRouter, Depends
3+
from fastapi import APIRouter, Depends, Header, HTTPException
44
from sqlalchemy.orm import Session
55

66
from api.deps import get_db
7-
from schemas.schools import SearchRequest, SearchResponse
7+
from core.logging import get_logger
8+
from schemas.schools import SchoolProfileResponse, SearchRequest, SearchResponse
89
from services.schools import SchoolService
910

1011
router = APIRouter(prefix="/schools", tags=["schools"])
12+
logger = get_logger(__name__)
1113

1214

1315
def get_school_service(db: Session = Depends(get_db)) -> SchoolService:
@@ -25,3 +27,28 @@ def search_schools(
2527
service: SchoolService = Depends(get_school_service),
2628
) -> SearchResponse:
2729
return service.search_schools(filters)
30+
31+
32+
@router.get(
33+
"/{school_id}",
34+
response_model=SchoolProfileResponse,
35+
summary="School profile",
36+
description="Returns a full structured school profile with explicit missing-data metadata.",
37+
)
38+
def get_school_profile(
39+
school_id: int,
40+
service: SchoolService = Depends(get_school_service),
41+
x_request_id: Annotated[str | None, Header(alias="X-Request-ID")] = None,
42+
) -> SchoolProfileResponse:
43+
profile = service.get_school_profile(school_id)
44+
if profile is None:
45+
logger.info(
46+
"school_profile_not_found",
47+
extra={
48+
"request_id": x_request_id,
49+
"school_id": school_id,
50+
"failure_reason": "school_id_not_found",
51+
},
52+
)
53+
raise HTTPException(status_code=404, detail="School not found.")
54+
return profile

apps/api/core/errors.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from fastapi import Request
22
from fastapi.exceptions import RequestValidationError
33
from fastapi.responses import JSONResponse
4+
from pydantic import ValidationError as PydanticValidationError
45
from starlette.exceptions import HTTPException as StarletteHTTPException
56
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY, HTTP_500_INTERNAL_SERVER_ERROR
67

@@ -39,6 +40,18 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
3940
)
4041

4142

43+
async def pydantic_validation_exception_handler(request: Request, exc: PydanticValidationError) -> JSONResponse:
44+
logger.info(
45+
"pydantic_validation_exception",
46+
extra={"path": request.url.path, "errors": exc.errors()},
47+
)
48+
return _error_response(
49+
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
50+
code="validation_error",
51+
message="Request validation failed.",
52+
)
53+
54+
4255
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
4356
logger.exception(
4457
"unhandled_exception",

apps/api/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from fastapi import FastAPI
22
from fastapi.exceptions import RequestValidationError
3+
from pydantic import ValidationError as PydanticValidationError
34
from starlette.exceptions import HTTPException as StarletteHTTPException
45

56
from api.routes.health import router as health_router
67
from api.routes.schools import router as schools_router
78
from core.config import get_settings
89
from core.errors import (
910
http_exception_handler,
11+
pydantic_validation_exception_handler,
1012
unhandled_exception_handler,
1113
validation_exception_handler,
1214
)
@@ -26,6 +28,7 @@ def create_app() -> FastAPI:
2628
app.include_router(schools_router)
2729
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
2830
app.add_exception_handler(RequestValidationError, validation_exception_handler)
31+
app.add_exception_handler(PydanticValidationError, pydantic_validation_exception_handler)
2932
app.add_exception_handler(Exception, unhandled_exception_handler)
3033
return app
3134

apps/api/pytest.ini

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
[pytest]
2-
pythonpath = .
2+
pythonpath =
3+
../..
4+
.
35
testpaths = tests
46
addopts = -q

apps/api/repositories/schools.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from sqlalchemy.orm import Session
55

66
from core.logging import get_logger
7-
from models.school import School, SchoolAcademics, SchoolCosts
7+
from models.school import School, SchoolAcademics, SchoolCampusLife, SchoolCosts, SchoolOutcomes
88
from repositories.base import BaseRepository
99
from schemas.schools import SchoolSearchResult, SearchRequest
1010

@@ -15,9 +15,41 @@ class SchoolRepository(BaseRepository[School]):
1515
def __init__(self, db: Session) -> None:
1616
super().__init__(db)
1717

18-
def get_school_by_id(self, school_id: int) -> School | None:
19-
"""Example repository method for future routes and services."""
20-
return self.db.get(School, school_id)
18+
def get_school_profile_row(self, school_id: int) -> dict[str, object] | None:
19+
query = (
20+
select(
21+
School.id.label("school_id"),
22+
School.name,
23+
School.city,
24+
School.state,
25+
School.region,
26+
School.type,
27+
School.setting,
28+
School.undergraduate_enrollment.label("enrollment"),
29+
SchoolAcademics.top_majors,
30+
SchoolAcademics.graduation_rate,
31+
SchoolAcademics.retention_rate,
32+
SchoolAcademics.student_faculty_ratio,
33+
SchoolCosts.tuition_in_state,
34+
SchoolCosts.tuition_out_state,
35+
SchoolCosts.net_price,
36+
SchoolCosts.average_aid,
37+
SchoolCosts.debt_median,
38+
SchoolOutcomes.median_earnings,
39+
SchoolOutcomes.repayment_rate,
40+
SchoolCampusLife.housing_available,
41+
SchoolCampusLife.sports_division,
42+
SchoolCampusLife.greek_life_rate,
43+
SchoolCampusLife.culture_tags,
44+
)
45+
.join(SchoolAcademics, SchoolAcademics.school_id == School.id, isouter=True)
46+
.join(SchoolCosts, SchoolCosts.school_id == School.id, isouter=True)
47+
.join(SchoolOutcomes, SchoolOutcomes.school_id == School.id, isouter=True)
48+
.join(SchoolCampusLife, SchoolCampusLife.school_id == School.id, isouter=True)
49+
.where(School.id == school_id)
50+
)
51+
row = self.db.execute(query).mappings().one_or_none()
52+
return dict(row) if row is not None else None
2153

2254
def search_schools(self, filters: SearchRequest) -> tuple[list[SchoolSearchResult], int]:
2355
base_query = (

apps/api/schemas/schools.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,60 @@ class SchoolProfile(BaseModel):
3737
campus_tags: list[str] = Field(default_factory=list)
3838

3939

40+
class SchoolProfileAcademics(BaseModel):
41+
majors: list[str] | None = None
42+
popular_majors: list[str] | None = None
43+
graduation_rate: float | None = None
44+
retention_rate: float | None = None
45+
student_faculty_ratio: float | None = None
46+
47+
48+
class SchoolProfileCost(BaseModel):
49+
tuition_in_state: int | None = None
50+
tuition_out_state: int | None = None
51+
net_price: int | None = None
52+
average_aid: int | None = None
53+
debt_median: int | None = None
54+
55+
56+
class SchoolProfileOutcomes(BaseModel):
57+
median_earnings: int | None = None
58+
completion_rate: float | None = None
59+
repayment_rate: float | None = None
60+
outcome_percentiles: dict[str, float] | None = None
61+
62+
63+
class SchoolProfileCampusLife(BaseModel):
64+
sports: str | None = None
65+
greek_life: float | None = None
66+
housing: bool | None = None
67+
weather_band: str | None = None
68+
diversity_metrics: dict[str, float] | None = None
69+
culture_tags: list[str] | None = None
70+
71+
72+
class SchoolProfileResponse(BaseModel):
73+
school_id: int
74+
name: str
75+
city: str
76+
state: str
77+
region: str
78+
type: str
79+
setting: str
80+
enrollment: int | None = None
81+
academics: SchoolProfileAcademics
82+
cost: SchoolProfileCost
83+
outcomes: SchoolProfileOutcomes
84+
campus_life: SchoolProfileCampusLife
85+
data_fields_missing: list[str] = Field(default_factory=list)
86+
data_confidence_score: float
87+
fit_score: float | None = None
88+
category_scores: dict[str, float] = Field(default_factory=dict)
89+
top_reasons: list[str] = Field(default_factory=list)
90+
top_tradeoffs: list[str] = Field(default_factory=list)
91+
similar_schools: list[dict[str, object]] = Field(default_factory=list)
92+
93+
4094
class SchoolSearchResult(BaseModel):
4195
school_id: int
4296
name: str

apps/api/services/schools.py

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,106 @@
11
from sqlalchemy.orm import Session
22

3-
from models.school import School
43
from repositories.schools import SchoolRepository
5-
from schemas.schools import SearchRequest, SearchResponse
4+
from schemas.schools import (
5+
SchoolProfileAcademics,
6+
SchoolProfileCampusLife,
7+
SchoolProfileCost,
8+
SchoolProfileOutcomes,
9+
SchoolProfileResponse,
10+
SearchRequest,
11+
SearchResponse,
12+
)
13+
14+
15+
PROFILE_COMPLETENESS_FIELDS = (
16+
"school_id",
17+
"name",
18+
"city",
19+
"state",
20+
"region",
21+
"type",
22+
"setting",
23+
"enrollment",
24+
"academics.majors",
25+
"academics.popular_majors",
26+
"academics.graduation_rate",
27+
"academics.retention_rate",
28+
"academics.student_faculty_ratio",
29+
"cost.tuition_in_state",
30+
"cost.tuition_out_state",
31+
"cost.net_price",
32+
"cost.average_aid",
33+
"cost.debt_median",
34+
"outcomes.median_earnings",
35+
"outcomes.completion_rate",
36+
"outcomes.repayment_rate",
37+
"outcomes.outcome_percentiles",
38+
"campus_life.sports",
39+
"campus_life.greek_life",
40+
"campus_life.housing",
41+
"campus_life.weather_band",
42+
"campus_life.diversity_metrics",
43+
"campus_life.culture_tags",
44+
)
645

746

847
class SchoolService:
948
def __init__(self, db: Session) -> None:
1049
self.repository = SchoolRepository(db)
1150

12-
def get_school_by_id(self, school_id: int) -> School | None:
13-
return self.repository.get_school_by_id(school_id)
51+
def get_school_profile(self, school_id: int) -> SchoolProfileResponse | None:
52+
row = self.repository.get_school_profile_row(school_id)
53+
if row is None:
54+
return None
55+
56+
top_majors = row["top_majors"]
57+
culture_tags = row["culture_tags"]
58+
profile = SchoolProfileResponse(
59+
school_id=int(row["school_id"]),
60+
name=str(row["name"]),
61+
city=str(row["city"]),
62+
state=str(row["state"]),
63+
region=str(row["region"]),
64+
type=str(row["type"]),
65+
setting=str(row["setting"]),
66+
enrollment=row["enrollment"],
67+
academics=SchoolProfileAcademics(
68+
majors=top_majors,
69+
popular_majors=top_majors,
70+
graduation_rate=self._to_float(row["graduation_rate"]),
71+
retention_rate=self._to_float(row["retention_rate"]),
72+
student_faculty_ratio=self._to_float(row["student_faculty_ratio"]),
73+
),
74+
cost=SchoolProfileCost(
75+
tuition_in_state=row["tuition_in_state"],
76+
tuition_out_state=row["tuition_out_state"],
77+
net_price=row["net_price"],
78+
average_aid=row["average_aid"],
79+
debt_median=row["debt_median"],
80+
),
81+
outcomes=SchoolProfileOutcomes(
82+
median_earnings=row["median_earnings"],
83+
completion_rate=None,
84+
repayment_rate=self._to_float(row["repayment_rate"]),
85+
outcome_percentiles=None,
86+
),
87+
campus_life=SchoolProfileCampusLife(
88+
sports=row["sports_division"],
89+
greek_life=self._to_float(row["greek_life_rate"]),
90+
housing=row["housing_available"],
91+
weather_band=None,
92+
diversity_metrics=None,
93+
culture_tags=culture_tags,
94+
),
95+
data_confidence_score=0,
96+
)
97+
missing_fields = self._missing_fields(profile)
98+
profile.data_fields_missing = missing_fields
99+
profile.data_confidence_score = round(
100+
(len(PROFILE_COMPLETENESS_FIELDS) - len(missing_fields)) / len(PROFILE_COMPLETENESS_FIELDS),
101+
4,
102+
)
103+
return profile
14104

15105
def search_schools(self, filters: SearchRequest) -> SearchResponse:
16106
results, total_results = self.repository.search_schools(filters)
@@ -21,3 +111,17 @@ def search_schools(self, filters: SearchRequest) -> SearchResponse:
21111
total_results=total_results,
22112
has_next=filters.page * filters.page_size < total_results,
23113
)
114+
115+
def _missing_fields(self, profile: SchoolProfileResponse) -> list[str]:
116+
payload = profile.model_dump()
117+
missing: list[str] = []
118+
for field in PROFILE_COMPLETENESS_FIELDS:
119+
value: object = payload
120+
for part in field.split("."):
121+
value = value[part] if isinstance(value, dict) else None
122+
if value is None:
123+
missing.append(field)
124+
return missing
125+
126+
def _to_float(self, value: object) -> float | None:
127+
return float(value) if value is not None else None

0 commit comments

Comments
 (0)