Coverage for src/data_api/api/routers/courses.py: 100%
63 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
1"""Latest and semester-specific normalized course queries."""
3from typing import Annotated
5from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response
6from pydantic import ValidationError
8from data_api.api.schemas import courses as schemas
9from data_api.api.schemas.errors import VALIDATION_ERROR_RESPONSES, ErrorResponse
10from data_api.api.schemas.responses import COUNTED_SNAPSHOT_RESPONSES
11from data_api.data.nthudata import Snapshot
12from data_api.domain.courses.models import Conditions
13from data_api.domain.courses.services import UnknownSemester
14from data_api.models.courses import SemesterCode
15from data_api.runtime import get_runtime
17router = APIRouter(responses={**COUNTED_SNAPSHOT_RESPONSES, **VALIDATION_ERROR_RESPONSES})
18COURSE_QUERY_ERROR_RESPONSES = {
19 422: {
20 **VALIDATION_ERROR_RESPONSES[422],
21 "description": "Invalid condition grammar, matcher types, query limits or regular expression",
22 }
23}
24SEMESTER_ERRORS = {404: {"model": ErrorResponse, "description": "Semester is not published"}}
27async def latest_snapshot() -> Snapshot[list[schemas.CourseData]]:
28 return await get_runtime().courses.get_snapshot()
31async def semester_snapshot(semester: SemesterCode) -> Snapshot[list[schemas.CourseData]]:
32 try:
33 return await get_runtime().courses.get_snapshot(semester)
34 except UnknownSemester as exc:
35 raise HTTPException(404, detail="Semester is not published.") from exc
38LatestSnapshot = Annotated[Snapshot[list[schemas.CourseData]], Depends(latest_snapshot)]
39SemesterSnapshot = Annotated[Snapshot[list[schemas.CourseData]], Depends(semester_snapshot)]
40CourseFilters = Annotated[schemas.CourseGetParams, Query()]
41CourseQuery = Annotated[schemas.CourseQueryCondition | schemas.CourseCondition, Body()]
44def counted_response[T](
45 response: Response, snapshot: Snapshot[list[T]], result: list[T]
46) -> list[T]:
47 response.headers["X-Total-Count"] = str(len(result))
48 if snapshot.version is not None:
49 response.headers["X-Data-Commit-Hash"] = snapshot.version
50 return result
53def filter_courses(
54 response: Response,
55 snapshot: Snapshot[list[schemas.CourseData]],
56 filters: schemas.CourseGetParams,
57) -> list[schemas.CourseData]:
58 condition = Conditions(list_build_target=[])
59 for field_name, value in filters.model_dump(
60 mode="json", by_alias=True, exclude_none=True, exclude={"type"}
61 ).items():
62 if value == "":
63 continue
64 try:
65 query = schemas.CourseCondition(
66 row_field=field_name, matcher=value, regex_match=isinstance(value, str)
67 )
68 except ValidationError as exc:
69 raise HTTPException(
70 422, detail=f"Invalid regular expression for course field '{field_name}'."
71 ) from exc
72 condition &= Conditions(query.row_field, query.matcher, query.regex_match)
73 result = get_runtime().courses.query(snapshot.data, condition, filters.type)
74 return counted_response(response, snapshot, result)
77def query_courses(
78 response: Response,
79 snapshot: Snapshot[list[schemas.CourseData]],
80 query: schemas.CourseQueryCondition | schemas.CourseCondition,
81) -> list[schemas.CourseData]:
82 body = query.model_dump(mode="json")
83 condition = Conditions(list_build_target=[body] if isinstance(body, dict) else body)
84 result = get_runtime().courses.query(snapshot.data, condition)
85 return counted_response(response, snapshot, result)
88@router.get(
89 "",
90 response_model=list[schemas.CourseData],
91 operation_id="getCourses",
92 responses=VALIDATION_ERROR_RESPONSES,
93)
94async def get_courses(response: Response, filters: CourseFilters, snapshot: LatestSnapshot):
95 """查詢最新課程。文字欄位使用 regex,數值與布林精確比對;所有條件以 AND 組合。"""
96 return filter_courses(response, snapshot, filters)
99@router.get("/semesters", response_model=list[SemesterCode], operation_id="getCourseSemesters")
100async def get_semesters(response: Response):
101 """列出已公布的學期碼,由新到舊排序。"""
102 snapshot = await get_runtime().courses.get_semesters()
103 return counted_response(response, snapshot, snapshot.data)
106@router.post(
107 "/query",
108 response_model=list[schemas.CourseData],
109 operation_id="queryCourses",
110 responses=COURSE_QUERY_ERROR_RESPONSES,
111)
112async def search_courses_by_condition(
113 response: Response, query_condition: CourseQuery, snapshot: LatestSnapshot
114):
115 """進階搜尋最新課程。條件以 and/or 交替並由左至右計算,支援巢狀陣列。
117 空陣列符合全部;最多 32 層與 1024 節點。matcher 使用欄位的原生型別,
118 regex_match 只適用文字,預設為 false。陣列葉節點採任一元素符合。
119 """
120 return query_courses(response, snapshot, query_condition)
123@router.get(
124 "/{semester}",
125 response_model=list[schemas.CourseData],
126 operation_id="getSemesterCourses",
127 responses={**SEMESTER_ERRORS, **VALIDATION_ERROR_RESPONSES},
128)
129async def get_semester_courses(
130 response: Response, filters: CourseFilters, snapshot: SemesterSnapshot
131):
132 """查詢指定學期課程,篩選方式與 GET /courses 相同,不自動改查其他學期。"""
133 return filter_courses(response, snapshot, filters)
136@router.post(
137 "/{semester}/query",
138 response_model=list[schemas.CourseData],
139 operation_id="querySemesterCourses",
140 responses={**SEMESTER_ERRORS, **COURSE_QUERY_ERROR_RESPONSES},
141)
142async def query_semester_courses(
143 response: Response, query_condition: CourseQuery, snapshot: SemesterSnapshot
144):
145 """進階搜尋指定學期,條件文法與 POST /courses/query 相同。"""
146 return query_courses(response, snapshot, query_condition)