Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
6549dce
1
Parent(s): ade3f94
deploy: backend from c237ff2
Browse files- main.py +5 -2
- routes/practice.py +1 -1
- services/user_provisioning_service.py +2 -0
- tests/test_api.py +23 -0
- tests/test_practice_router.py +23 -0
- tests/test_quiz_generation_regression.py +15 -1
- tests/test_user_provisioning_service.py +36 -0
main.py
CHANGED
|
@@ -6774,6 +6774,7 @@ async def commit_student_account_import(
|
|
| 6774 |
}
|
| 6775 |
if not existing_profile_doc:
|
| 6776 |
profile_payload["createdAt"] = FIRESTORE_SERVER_TIMESTAMP
|
|
|
|
| 6777 |
|
| 6778 |
client.collection("users").document(target_uid).set(profile_payload, merge=True)
|
| 6779 |
|
|
@@ -7691,6 +7692,7 @@ async def create_student_account_for_teacher(
|
|
| 7691 |
"atRiskSubjects": [],
|
| 7692 |
"hasTakenDiagnostic": False,
|
| 7693 |
"adviserTeacherId": adviser_teacher_id,
|
|
|
|
| 7694 |
"createdAt": FIRESTORE_SERVER_TIMESTAMP,
|
| 7695 |
"updatedAt": FIRESTORE_SERVER_TIMESTAMP,
|
| 7696 |
}
|
|
@@ -11757,16 +11759,17 @@ def _validate_quiz_questions(
|
|
| 11757 |
|
| 11758 |
if options:
|
| 11759 |
options = [to_plain_text(o, single_line=True, strip_option_label=True) for o in options]
|
|
|
|
| 11760 |
validated.append(QuizQuestion(
|
| 11761 |
questionType=question_type,
|
| 11762 |
question=clean_llm_text(q.get("question", "")),
|
| 11763 |
-
correctAnswer=
|
| 11764 |
options=options,
|
| 11765 |
bloomLevel=bloom_level,
|
| 11766 |
difficulty=difficulty,
|
| 11767 |
topic=question_topic,
|
| 11768 |
points=q.get("points", points_map.get(difficulty, 3)),
|
| 11769 |
-
explanation=clean_llm_text(q.get("explanation", "")) or "
|
| 11770 |
provenance=question_provenance,
|
| 11771 |
))
|
| 11772 |
|
|
|
|
| 6774 |
}
|
| 6775 |
if not existing_profile_doc:
|
| 6776 |
profile_payload["createdAt"] = FIRESTORE_SERVER_TIMESTAMP
|
| 6777 |
+
profile_payload["guideOfferPending"] = True
|
| 6778 |
|
| 6779 |
client.collection("users").document(target_uid).set(profile_payload, merge=True)
|
| 6780 |
|
|
|
|
| 7692 |
"atRiskSubjects": [],
|
| 7693 |
"hasTakenDiagnostic": False,
|
| 7694 |
"adviserTeacherId": adviser_teacher_id,
|
| 7695 |
+
"guideOfferPending": True,
|
| 7696 |
"createdAt": FIRESTORE_SERVER_TIMESTAMP,
|
| 7697 |
"updatedAt": FIRESTORE_SERVER_TIMESTAMP,
|
| 7698 |
}
|
|
|
|
| 11759 |
|
| 11760 |
if options:
|
| 11761 |
options = [to_plain_text(o, single_line=True, strip_option_label=True) for o in options]
|
| 11762 |
+
correct_answer = to_plain_text(q.get("correctAnswer", ""), single_line=True, strip_option_label=True)
|
| 11763 |
validated.append(QuizQuestion(
|
| 11764 |
questionType=question_type,
|
| 11765 |
question=clean_llm_text(q.get("question", "")),
|
| 11766 |
+
correctAnswer=correct_answer,
|
| 11767 |
options=options,
|
| 11768 |
bloomLevel=bloom_level,
|
| 11769 |
difficulty=difficulty,
|
| 11770 |
topic=question_topic,
|
| 11771 |
points=q.get("points", points_map.get(difficulty, 3)),
|
| 11772 |
+
explanation=clean_llm_text(q.get("explanation", "")) or f"Correct answer: {correct_answer}",
|
| 11773 |
provenance=question_provenance,
|
| 11774 |
))
|
| 11775 |
|
routes/practice.py
CHANGED
|
@@ -250,7 +250,7 @@ async def generate_practice(request: Request, body: PracticeGenerateRequest):
|
|
| 250 |
question=q.get("question", ""),
|
| 251 |
options=q.get("options", ["", "", "", ""]),
|
| 252 |
correct_index=correct_idx,
|
| 253 |
-
explanation=q.get("explanation"
|
| 254 |
competency=q.get("competency", body.competency),
|
| 255 |
difficulty=q.get("difficulty", body.difficulty),
|
| 256 |
bloomsLevel=q.get("bloomsLevel", "Apply"),
|
|
|
|
| 250 |
question=q.get("question", ""),
|
| 251 |
options=q.get("options", ["", "", "", ""]),
|
| 252 |
correct_index=correct_idx,
|
| 253 |
+
explanation=q.get("explanation") or "",
|
| 254 |
competency=q.get("competency", body.competency),
|
| 255 |
difficulty=q.get("difficulty", body.difficulty),
|
| 256 |
bloomsLevel=q.get("bloomsLevel", "Apply"),
|
services/user_provisioning_service.py
CHANGED
|
@@ -269,6 +269,8 @@ class UserProvisioningService:
|
|
| 269 |
|
| 270 |
profile_payload = self._build_profile_payload(user_input, role_lower, normalized_status)
|
| 271 |
profile_payload["createdAt"] = self._firestore_server_timestamp
|
|
|
|
|
|
|
| 272 |
|
| 273 |
try:
|
| 274 |
firestore_client.collection("users").document(uid).set(profile_payload, merge=True)
|
|
|
|
| 269 |
|
| 270 |
profile_payload = self._build_profile_payload(user_input, role_lower, normalized_status)
|
| 271 |
profile_payload["createdAt"] = self._firestore_server_timestamp
|
| 272 |
+
# New accounts are asked once whether to play the first-use guide; the app clears this flag.
|
| 273 |
+
profile_payload["guideOfferPending"] = True
|
| 274 |
|
| 275 |
try:
|
| 276 |
firestore_client.collection("users").document(uid).set(profile_payload, merge=True)
|
tests/test_api.py
CHANGED
|
@@ -2237,6 +2237,29 @@ class TestStudentAccountProvisioningImport:
|
|
| 2237 |
provisioned_profile = next(iter(users_store.values()))
|
| 2238 |
assert provisioned_profile.get("role") == "student"
|
| 2239 |
assert provisioned_profile.get("forcePasswordChange") is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2240 |
|
| 2241 |
@staticmethod
|
| 2242 |
def _move_firestore() -> _ProvisionFirestoreModule:
|
|
|
|
| 2237 |
provisioned_profile = next(iter(users_store.values()))
|
| 2238 |
assert provisioned_profile.get("role") == "student"
|
| 2239 |
assert provisioned_profile.get("forcePasswordChange") is True
|
| 2240 |
+
assert provisioned_profile.get("guideOfferPending") is True
|
| 2241 |
+
|
| 2242 |
+
def test_teacher_created_student_account_is_offered_the_first_use_guide(self):
|
| 2243 |
+
firestore = _ProvisionFirestoreModule({"users": {}, "managedStudents": {}, "classSectionOwnership": {}, "accessAuditLogs": {}})
|
| 2244 |
+
|
| 2245 |
+
with patch.object(main_module, "firebase_firestore", firestore), patch.object(main_module, "_firebase_ready", True), patch.object(main_module.firebase_auth, "verify_id_token", return_value={
|
| 2246 |
+
"uid": "teacher-uid",
|
| 2247 |
+
"email": "teacher@example.com",
|
| 2248 |
+
"role": "teacher",
|
| 2249 |
+
}), patch.object(main_module.firebase_auth, "get_user_by_email", side_effect=Exception("user not found")), patch.object(main_module.firebase_auth, "create_user", return_value=type("AuthUser", (), {"uid": "auth-created-1"})()):
|
| 2250 |
+
response = client.post(
|
| 2251 |
+
"/api/teacher/create-student-account",
|
| 2252 |
+
json={
|
| 2253 |
+
"name": "Ana Cruz",
|
| 2254 |
+
"email": "ana@student.com",
|
| 2255 |
+
"temporary_password": "Secure123!",
|
| 2256 |
+
"section": "STEM-A",
|
| 2257 |
+
"adviser_teacher_id": "teacher-uid",
|
| 2258 |
+
},
|
| 2259 |
+
)
|
| 2260 |
+
|
| 2261 |
+
assert response.status_code == 200
|
| 2262 |
+
assert firestore.client().store["users"]["auth-created-1"]["guideOfferPending"] is True
|
| 2263 |
|
| 2264 |
@staticmethod
|
| 2265 |
def _move_firestore() -> _ProvisionFirestoreModule:
|
tests/test_practice_router.py
CHANGED
|
@@ -97,6 +97,29 @@ class TestPracticeGenerate:
|
|
| 97 |
assert "generated_at" in data
|
| 98 |
assert data["questions"][0]["id"] == "q1"
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
def test_generate_rejects_mismatched_user(self):
|
| 101 |
"""Request with mismatched auth userId should return 403."""
|
| 102 |
response = client.post(
|
|
|
|
| 97 |
assert "generated_at" in data
|
| 98 |
assert data["questions"][0]["id"] == "q1"
|
| 99 |
|
| 100 |
+
@patch("routes.practice._get_firestore", return_value=None)
|
| 101 |
+
@patch("routes.practice._call_claude")
|
| 102 |
+
def test_missing_explanation_is_left_empty_instead_of_a_shared_sentence(self, mock_call, _mock_db):
|
| 103 |
+
"""An omitted or null explanation must not become the same canned text on every question."""
|
| 104 |
+
options = ["3", "4", "5", "6"]
|
| 105 |
+
mock_call.return_value = json.dumps({
|
| 106 |
+
"questions": [
|
| 107 |
+
{"id": "q1", "question": "2+2?", "options": options, "correct_index": 1, "explanation": "Add 2 and 2."},
|
| 108 |
+
{"id": "q2", "question": "3+3?", "options": options, "correct_index": 3},
|
| 109 |
+
{"id": "q3", "question": "4+4?", "options": options, "correct_index": 3, "explanation": None},
|
| 110 |
+
]
|
| 111 |
+
})
|
| 112 |
+
|
| 113 |
+
user_id = "test_user_123"
|
| 114 |
+
response = client.post(
|
| 115 |
+
"/api/practice/generate",
|
| 116 |
+
json={"userId": user_id, "subject": "Algebra", "competency": "Linear Equations", "difficulty": "Practice", "count": 3},
|
| 117 |
+
headers=_auth_header(user_id),
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
assert response.status_code == 200
|
| 121 |
+
assert [q["explanation"] for q in response.json()["questions"]] == ["Add 2 and 2.", "", ""]
|
| 122 |
+
|
| 123 |
def test_generate_rejects_mismatched_user(self):
|
| 124 |
"""Request with mismatched auth userId should return 403."""
|
| 125 |
response = client.post(
|
tests/test_quiz_generation_regression.py
CHANGED
|
@@ -4,7 +4,7 @@ import pytest
|
|
| 4 |
from fastapi.testclient import TestClient
|
| 5 |
from pydantic import ValidationError
|
| 6 |
|
| 7 |
-
from main import app
|
| 8 |
from routes.quiz_generation_routes import QuizGenerationRequest, _build_quiz_generation_prompt
|
| 9 |
|
| 10 |
pytestmark = pytest.mark.usefixtures("isolated_mock_student_auth")
|
|
@@ -49,3 +49,17 @@ def test_quiz_type_fallback_prompt_handles_empty_type_list():
|
|
| 49 |
"Algebra", "Math", None, 1, [], "easy", "", grade_level="Grade 11"
|
| 50 |
)
|
| 51 |
assert "multiple_choice" in prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from fastapi.testclient import TestClient
|
| 5 |
from pydantic import ValidationError
|
| 6 |
|
| 7 |
+
from main import _validate_quiz_questions, app
|
| 8 |
from routes.quiz_generation_routes import QuizGenerationRequest, _build_quiz_generation_prompt
|
| 9 |
|
| 10 |
pytestmark = pytest.mark.usefixtures("isolated_mock_student_auth")
|
|
|
|
| 49 |
"Algebra", "Math", None, 1, [], "easy", "", grade_level="Grade 11"
|
| 50 |
)
|
| 51 |
assert "multiple_choice" in prompt
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_missing_quiz_explanation_falls_back_to_the_questions_own_answer():
|
| 55 |
+
questions = [
|
| 56 |
+
{"questionType": "identification", "question": "Define slope", "correctAnswer": "rise over run", "explanation": "Slope is rise over run."},
|
| 57 |
+
{"questionType": "identification", "question": "Define intercept", "correctAnswer": "where the line crosses an axis"},
|
| 58 |
+
{"questionType": "identification", "question": "Define vertex", "correctAnswer": "the turning point", "explanation": " "},
|
| 59 |
+
]
|
| 60 |
+
validated = _validate_quiz_questions(questions, [])
|
| 61 |
+
assert [q.explanation for q in validated] == [
|
| 62 |
+
"Slope is rise over run.",
|
| 63 |
+
"Correct answer: where the line crosses an axis",
|
| 64 |
+
"Correct answer: the turning point",
|
| 65 |
+
]
|
tests/test_user_provisioning_service.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
from services.user_provisioning_service import AdminCreateUserInput, UserProvisioningService
|
| 2 |
from unittest.mock import MagicMock
|
| 3 |
|
|
@@ -28,3 +29,38 @@ def test_teacher_profile_does_not_require_or_assign_a_section():
|
|
| 28 |
assert profile["role"] == "teacher"
|
| 29 |
assert "section" not in profile
|
| 30 |
assert "classSectionId" not in profile
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
from services.user_provisioning_service import AdminCreateUserInput, UserProvisioningService
|
| 3 |
from unittest.mock import MagicMock
|
| 4 |
|
|
|
|
| 29 |
assert profile["role"] == "teacher"
|
| 30 |
assert "section" not in profile
|
| 31 |
assert "classSectionId" not in profile
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.mark.parametrize("role", ["Student", "Teacher", "Admin"])
|
| 35 |
+
def test_created_accounts_are_offered_the_first_use_guide(role):
|
| 36 |
+
auth = MagicMock()
|
| 37 |
+
auth.get_user_by_email.side_effect = Exception("No user record found")
|
| 38 |
+
auth.create_user.return_value = MagicMock(uid="new-uid")
|
| 39 |
+
firestore_module = MagicMock()
|
| 40 |
+
users = firestore_module.client.return_value.collection.return_value
|
| 41 |
+
users.where.return_value.limit.return_value.stream.return_value = []
|
| 42 |
+
provisioning = UserProvisioningService(
|
| 43 |
+
firebase_auth_module=auth,
|
| 44 |
+
firestore_module=firestore_module,
|
| 45 |
+
firestore_server_timestamp="server-timestamp",
|
| 46 |
+
email_service=MagicMock(),
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
uid = provisioning.create_user(
|
| 50 |
+
AdminCreateUserInput(
|
| 51 |
+
name="New User",
|
| 52 |
+
email="new.user@example.com",
|
| 53 |
+
password="Secure123!",
|
| 54 |
+
confirm_password="Secure123!",
|
| 55 |
+
role=role,
|
| 56 |
+
status="Active",
|
| 57 |
+
grade="Grade 11",
|
| 58 |
+
section="Section A",
|
| 59 |
+
lrn="123456789012",
|
| 60 |
+
)
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
assert uid == "new-uid"
|
| 64 |
+
users.document.assert_called_with("new-uid")
|
| 65 |
+
(profile,) = users.document.return_value.set.call_args.args
|
| 66 |
+
assert profile["guideOfferPending"] is True
|