Coverage for server / services / growthbook / organization.py: 78%
77 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""
2Load school and district records for the authenticated teacher/student.
3"""
5from typing import Any, Optional
7from beanie import PydanticObjectId
8from fastapi import HTTPException, Request, status
10from server.connection.database import staff_admin_db
11from server.services.growthbook.dependencies import get_targeting_context
12from server.services.growthbook.context import resolve_targeting_context
13from server.validators.feature_enum import (
14 PLAN_DESCRIPTIONS,
15 PLAN_DISPLAY_NAMES,
16 PlanEnum,
17 normalize_plan_type,
18)
20_SCHOOL_FIELDS = (
21 "school_name",
22 "address",
23 "state",
24 "city",
25 "zip_code",
26 "street",
27 "phone",
28 "email",
29 "school_logo",
30 "status",
31)
33_DISTRICT_FIELDS = (
34 "district_name",
35 "district_address",
36 "district_logo",
37 "contact_first_name",
38 "contact_middle_name",
39 "contact_last_name",
40 "contact_email",
41 "contact_phone",
42)
45def _require_admin_db():
46 if staff_admin_db is None:
47 raise HTTPException(
48 status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
49 detail={
50 "message": "Admin database is not configured.",
51 "reason": "staff_admin_db_unconfigured",
52 },
53 )
54 return staff_admin_db
57def _serialize_document(
58 doc: Optional[dict[str, Any]],
59 *,
60 fields: tuple[str, ...],
61) -> Optional[dict[str, Any]]:
62 if not doc:
63 return None
64 payload: dict[str, Any] = {"id": str(doc["_id"])}
65 for field in fields:
66 if field in doc and doc[field] is not None:
67 payload[field] = doc[field]
68 return payload
71async def fetch_school_document(school_id: str) -> Optional[dict[str, Any]]:
72 try:
73 school_oid = PydanticObjectId(school_id)
74 except Exception as exc:
75 raise HTTPException(
76 status_code=status.HTTP_400_BAD_REQUEST,
77 detail=f"Invalid school id: {school_id}",
78 ) from exc
80 admin_db = _require_admin_db()
81 return await admin_db["school_collection"].find_one({"_id": school_oid})
84async def fetch_district_document(district_id: str) -> Optional[dict[str, Any]]:
85 try:
86 district_oid = PydanticObjectId(district_id)
87 except Exception as exc:
88 raise HTTPException(
89 status_code=status.HTTP_400_BAD_REQUEST,
90 detail=f"Invalid district id: {district_id}",
91 ) from exc
93 admin_db = _require_admin_db()
94 return await admin_db["district_collection"].find_one({"_id": district_oid})
97def _plan_payload(plan: str) -> dict[str, str]:
98 normalized = normalize_plan_type(plan)
99 return {
100 "current_plan": normalized,
101 "plan_name": PLAN_DISPLAY_NAMES.get(
102 normalized,
103 normalized.replace("_", " ").title(),
104 ),
105 "plan_description": PLAN_DESCRIPTIONS.get(normalized, ""),
106 }
110# ---------------------------------------------------------------------------
111# Accounts with no district scope
112# ---------------------------------------------------------------------------
113#
114# These three are READS of the caller's own organisation. They used to answer
115# 403 for an account with no school_id, because they resolved scope through
116# get_targeting_context(require_district=True).
117#
118# 403 means the server understood and REFUSES (RFC 9110 15.5.4) — a decision of
119# "no". Nothing is refused here; there is simply no school linked yet, which is
120# a data-completeness condition rather than an authorization one. And the SPA
121# fails OPEN when these calls error, so an unprovisioned account hit that path
122# permanently and was handed a fully unlocked UI in which every action then
123# 403'd — the worst of both answers. This mirrors what #331 did for /effective.
124#
125# THE ENVELOPE DOES NOT CHANGE, only the values. A caller iterating a known
126# shape keeps working; it reads null where it used to read an object. AIP-156
127# treats a singleton like this as always resolving — "my school" is not a keyed
128# lookup that can fail to find anything, it is a relationship that may be unset.
129#
130# A 404 IS STILL A 404 WHEN AN ID IS SET AND THE DOCUMENT IS MISSING. That is a
131# genuine not-found and a data-integrity problem worth surfacing; it is a
132# different condition from "no school is linked", and the two must not collapse
133# into one another.
136_NO_PLAN = {"current_plan": None, "plan_name": None, "plan_description": None}
139async def _scope(request: Request):
140 """Resolve the caller's scope WITHOUT demanding a district."""
141 return await resolve_targeting_context(request, require_district=False)
144async def build_organization_payload(request: Request) -> dict[str, Any]:
145 ctx = await _scope(request)
146 if not ctx.district_id and not ctx.school_id:
147 return {
148 "user_id": ctx.user_id,
149 "role": ctx.role,
150 "school_id": None,
151 "district_id": None,
152 **_NO_PLAN,
153 "school": None,
154 "district": None,
155 }
157 school_doc = await fetch_school_document(ctx.school_id) if ctx.school_id else None
158 district_doc = (
159 await fetch_district_document(ctx.district_id) if ctx.district_id else None
160 )
162 if ctx.school_id and not school_doc:
163 raise HTTPException(
164 status_code=status.HTTP_404_NOT_FOUND,
165 detail=f"School with ID {ctx.school_id} not found",
166 )
167 if ctx.district_id and not district_doc:
168 raise HTTPException(
169 status_code=status.HTTP_404_NOT_FOUND,
170 detail=f"District with ID {ctx.district_id} not found",
171 )
173 plan_info = _plan_payload(ctx.plan)
174 district_info = _serialize_document(district_doc, fields=_DISTRICT_FIELDS)
175 if district_info is not None:
176 district_info.update(plan_info)
177 district_info["feature_plan"] = (
178 district_doc.get("feature_plan") if district_doc else PlanEnum.FREE.value
179 )
181 return {
182 "user_id": ctx.user_id,
183 "role": ctx.role,
184 "school_id": ctx.school_id,
185 "district_id": ctx.district_id,
186 **plan_info,
187 "school": _serialize_document(school_doc, fields=_SCHOOL_FIELDS),
188 "district": district_info,
189 }
192async def build_school_payload(request: Request) -> dict[str, Any]:
193 ctx = await _scope(request)
194 if not ctx.school_id:
195 return {
196 "user_id": ctx.user_id,
197 "school_id": None,
198 "district_id": ctx.district_id,
199 "school": None,
200 }
202 school_doc = await fetch_school_document(ctx.school_id)
203 if not school_doc:
204 raise HTTPException(
205 status_code=status.HTTP_404_NOT_FOUND,
206 detail=f"School with ID {ctx.school_id} not found",
207 )
209 return {
210 "user_id": ctx.user_id,
211 "school_id": ctx.school_id,
212 "district_id": ctx.district_id,
213 "school": _serialize_document(school_doc, fields=_SCHOOL_FIELDS),
214 }
217async def build_district_plan_payload(request: Request) -> dict[str, Any]:
218 ctx = await _scope(request)
219 if not ctx.district_id:
220 return {
221 "user_id": ctx.user_id,
222 "school_id": ctx.school_id,
223 "district_id": None,
224 **_NO_PLAN,
225 "district": None,
226 }
228 district_doc = await fetch_district_document(ctx.district_id)
229 if not district_doc:
230 raise HTTPException(
231 status_code=status.HTTP_404_NOT_FOUND,
232 detail=f"District with ID {ctx.district_id} not found",
233 )
235 plan_info = _plan_payload(ctx.plan)
236 district_info = _serialize_document(district_doc, fields=_DISTRICT_FIELDS) or {}
237 district_info.update(plan_info)
238 district_info["feature_plan"] = district_doc.get("feature_plan")
240 return {
241 "user_id": ctx.user_id,
242 "school_id": ctx.school_id,
243 "district_id": ctx.district_id,
244 **plan_info,
245 "district": district_info,
246 }