Coverage for server / utilities / exam_catalog.py: 98%
59 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"""
2Strict global-assignment catalog: exactly one global assignment per tiered exam feature key.
4Maps GrowthBook keys `teacher.exam.{staar|tsi|sat|act}.{1-6}` to global_assignments documents.
5"""
7from typing import Any, Optional
9EXAM_SEGMENT_TO_ASSIGNMENT_TYPE: dict[str, str] = {
10 "staar": "STAAR",
11 "tsi": "TSI",
12 "sat": "SAT",
13 "act": "ACT",
14}
16ASSIGNMENT_TYPE_TO_EXAM_SEGMENT: dict[str, str] = {
17 v: k for k, v in EXAM_SEGMENT_TO_ASSIGNMENT_TYPE.items()
18}
20TIERED_EXAM_TYPES = frozenset(ASSIGNMENT_TYPE_TO_EXAM_SEGMENT.keys())
22TIERED_EXAM_FEATURE_KEYS = frozenset(
23 f"teacher.exam.{exam}.{slot}"
24 for exam in ("staar", "tsi", "sat", "act")
25 for slot in range(1, 7)
26)
29def tiered_exam_feature_keys() -> frozenset[str]:
30 return TIERED_EXAM_FEATURE_KEYS
33def _assignment_type_value(assignment_type: Any) -> str:
34 if hasattr(assignment_type, "value"):
35 return str(assignment_type.value).strip().upper()
36 return str(assignment_type or "").strip().upper()
39def _format_value(format_value: Any) -> str:
40 return str(format_value or "").strip().lower()
43def is_tiered_practice_global(assignment_type: Any, format_value: Any) -> bool:
44 return (
45 _assignment_type_value(assignment_type) in TIERED_EXAM_TYPES
46 and _format_value(format_value) == "practice"
47 )
50def parse_exam_feature_key(feature_key: str) -> tuple[str, int]:
51 normalized = (feature_key or "").strip().lower()
52 if normalized not in TIERED_EXAM_FEATURE_KEYS:
53 raise ValueError(
54 f"feature_key must be one of the 24 tiered exam keys "
55 f"(teacher.exam.{{staar|tsi|sat|act}}.{{1-6}}). Got: {feature_key!r}"
56 )
57 _prefix, _exam, exam_segment, slot_str = normalized.split(".")
58 return exam_segment, int(slot_str)
61def assignment_type_for_feature_key(feature_key: str) -> str:
62 exam_segment, _slot = parse_exam_feature_key(feature_key)
63 return EXAM_SEGMENT_TO_ASSIGNMENT_TYPE[exam_segment]
66def feature_key_for_assignment(assignment_type: Any, exam_slot: int) -> str:
67 exam_segment = ASSIGNMENT_TYPE_TO_EXAM_SEGMENT.get(_assignment_type_value(assignment_type))
68 if not exam_segment:
69 raise ValueError("assignment type must be STAAR, TSI, SAT, or ACT")
70 if exam_slot not in range(1, 7):
71 raise ValueError("exam_slot must be between 1 and 6")
72 key = f"teacher.exam.{exam_segment}.{exam_slot}"
73 if key not in TIERED_EXAM_FEATURE_KEYS:
74 raise ValueError(f"Invalid exam catalog key: {key}")
75 return key
78def validate_assignment_feature_key_rules(
79 *,
80 feature_key: Optional[str],
81 assignment_type: Any,
82 format_value: Any,
83) -> None:
84 type_value = _assignment_type_value(assignment_type)
85 format_norm = _format_value(format_value)
86 key_norm = (feature_key or "").strip().lower() or None
88 if is_tiered_practice_global(type_value, format_norm):
89 if not key_norm:
90 raise ValueError(
91 "feature_key is required for tiered practice globals "
92 "(STAAR/TSI/SAT/ACT with format=practice). "
93 "Use one of the 24 keys: teacher.exam.{staar|tsi|sat|act}.{1-6}."
94 )
95 elif key_norm:
96 raise ValueError(
97 "feature_key is only allowed on tiered practice globals "
98 "(type STAAR/TSI/SAT/ACT and format=practice)."
99 )
101 if not key_norm:
102 return
104 expected_type = assignment_type_for_feature_key(key_norm)
105 if type_value != expected_type:
106 raise ValueError(
107 f"feature_key {key_norm!r} requires type {expected_type}, got {type_value}."
108 )
111def catalog_entry(feature_key: str) -> dict[str, Any]:
112 from server.services.growthbook.registry import get_feature_definition, min_plan_for_feature
114 definition = get_feature_definition(feature_key)
115 exam_segment, slot = parse_exam_feature_key(feature_key)
116 return {
117 "feature_key": feature_key,
118 "assignment_type": EXAM_SEGMENT_TO_ASSIGNMENT_TYPE[exam_segment],
119 "exam_slot": slot,
120 "description": definition.description if definition else feature_key,
121 "min_plan": min_plan_for_feature(feature_key),
122 }
125def list_exam_catalog_entries() -> list[dict[str, Any]]:
126 order = ["staar", "tsi", "sat", "act"]
128 def sort_key(key: str) -> tuple[int, int]:
129 segment, slot = parse_exam_feature_key(key)
130 return order.index(segment), slot
132 return [catalog_entry(key) for key in sorted(TIERED_EXAM_FEATURE_KEYS, key=sort_key)]