github-actions[bot] commited on
Commit
56f4e10
·
1 Parent(s): 35426f1

🚀 Auto-deploy backend from GitHub (b157bce)

Browse files
Files changed (1) hide show
  1. main.py +294 -0
main.py CHANGED
@@ -383,6 +383,7 @@ ROLE_POLICIES: Dict[str, Set[str]] = {
383
  "/api/import/student-accounts/commit": TEACHER_OR_ADMIN,
384
  "/api/admin/users": ADMIN_ONLY,
385
  "/api/admin/users/bulk-action": ADMIN_ONLY,
 
386
  "/api/upload/course-materials": TEACHER_OR_ADMIN,
387
  "/api/upload/course-materials/recent": TEACHER_OR_ADMIN,
388
  "/api/teacher-materials/upload": TEACHER_OR_ADMIN,
@@ -5151,6 +5152,40 @@ class AdminDeleteUserResponse(BaseModel):
5151
  warnings: List[str] = Field(default_factory=list)
5152
 
5153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5154
  class AdminUserListItem(BaseModel):
5155
  uid: str
5156
  name: str
@@ -6773,6 +6808,265 @@ async def create_admin_user_and_notify(
6773
  raise HTTPException(status_code=500, detail="Failed to create user account")
6774
 
6775
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6776
  @app.delete("/api/admin/users", response_model=AdminDeleteUserResponse)
6777
  async def delete_admin_user_account(
6778
  request: Request,
 
383
  "/api/import/student-accounts/commit": TEACHER_OR_ADMIN,
384
  "/api/admin/users": ADMIN_ONLY,
385
  "/api/admin/users/bulk-action": ADMIN_ONLY,
386
+ "/api/teacher/create-student-account": TEACHER_OR_ADMIN,
387
  "/api/upload/course-materials": TEACHER_OR_ADMIN,
388
  "/api/upload/course-materials/recent": TEACHER_OR_ADMIN,
389
  "/api/teacher-materials/upload": TEACHER_OR_ADMIN,
 
5152
  warnings: List[str] = Field(default_factory=list)
5153
 
5154
 
5155
+ class CreateStudentAccountRequest(BaseModel):
5156
+ name: str
5157
+ email: str
5158
+ temporary_password: str
5159
+ lrn: Optional[str] = None
5160
+ grade: Optional[str] = None
5161
+ section: Optional[str] = None
5162
+ class_section_id: Optional[str] = None
5163
+ adviser_teacher_id: str
5164
+ adviser_teacher_name: Optional[str] = None
5165
+ school_year: Optional[str] = None
5166
+
5167
+ @field_validator("name", "email", "temporary_password", "adviser_teacher_id")
5168
+ @classmethod
5169
+ def _strip_required(cls, value: str) -> str:
5170
+ return str(value or "").strip()
5171
+
5172
+ @field_validator("lrn", "grade", "section", "class_section_id", "adviser_teacher_name", "school_year")
5173
+ @classmethod
5174
+ def _strip_optional(cls, value: Optional[str]) -> Optional[str]:
5175
+ if value is None:
5176
+ return None
5177
+ cleaned = value.strip()
5178
+ return cleaned or None
5179
+
5180
+
5181
+ class CreateStudentAccountResponse(BaseModel):
5182
+ success: bool
5183
+ uid: str
5184
+ email: str
5185
+ message: Optional[str] = None
5186
+ warnings: List[str] = Field(default_factory=list)
5187
+
5188
+
5189
  class AdminUserListItem(BaseModel):
5190
  uid: str
5191
  name: str
 
6808
  raise HTTPException(status_code=500, detail="Failed to create user account")
6809
 
6810
 
6811
+ @app.post("/api/teacher/create-student-account", response_model=CreateStudentAccountResponse)
6812
+ async def create_student_account_for_teacher(
6813
+ request: Request,
6814
+ payload: CreateStudentAccountRequest,
6815
+ ):
6816
+ """Provision a `role: 'student'` account on behalf of a teacher.
6817
+
6818
+ Used by the registered-first dashboard pipeline to convert a roster-only
6819
+ student (a row from an imported class record that has no Firebase Auth
6820
+ user) into a full system account. The teacher must be authenticated, and
6821
+ the request's `adviser_teacher_id` must match the caller's uid (admins may
6822
+ create accounts on behalf of any teacher).
6823
+ """
6824
+ user = get_current_user(request)
6825
+ if user.role not in {"teacher", "admin"}:
6826
+ raise HTTPException(status_code=403, detail="Forbidden for this role")
6827
+
6828
+ adviser_teacher_id = (payload.adviser_teacher_id or "").strip()
6829
+ if not adviser_teacher_id:
6830
+ raise HTTPException(status_code=400, detail="adviser_teacher_id is required")
6831
+ if user.role == "teacher" and adviser_teacher_id != user.uid:
6832
+ raise HTTPException(
6833
+ status_code=403,
6834
+ detail="Teachers may only create accounts where they are the adviser.",
6835
+ )
6836
+
6837
+ if not _firebase_ready or firebase_auth is None or firebase_firestore is None:
6838
+ raise HTTPException(status_code=503, detail="Authentication service unavailable")
6839
+
6840
+ name = (payload.name or "").strip()
6841
+ if not name:
6842
+ raise HTTPException(status_code=400, detail="Student name is required")
6843
+
6844
+ email = (payload.email or "").strip().lower()
6845
+ if not email or not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", email):
6846
+ raise HTTPException(status_code=400, detail="A valid email address is required")
6847
+
6848
+ temporary_password = (payload.temporary_password or "").strip()
6849
+ if len(temporary_password) < 8:
6850
+ raise HTTPException(status_code=400, detail="Temporary password must be at least 8 characters")
6851
+
6852
+ grade = (payload.grade or "").strip()
6853
+ section = (payload.section or "").strip()
6854
+ class_section_id = (payload.class_section_id or "").strip()
6855
+ if not class_section_id and grade and section:
6856
+ class_section_id = re.sub(r"\s+", "_", f"{grade}_{section}".lower())
6857
+
6858
+ lrn = (payload.lrn or "").strip()
6859
+ school_year = (payload.school_year or "").strip()
6860
+ adviser_name = (payload.adviser_teacher_name or "").strip()
6861
+
6862
+ warnings: List[str] = []
6863
+ auth_uid: Optional[str] = None
6864
+
6865
+ try:
6866
+ # Reject duplicates up-front to surface a friendly error message.
6867
+ try:
6868
+ existing_auth_user = cast(Any, firebase_auth).get_user_by_email(email)
6869
+ existing_uid = str(getattr(existing_auth_user, "uid", "") or "").strip()
6870
+ if existing_uid:
6871
+ raise HTTPException(
6872
+ status_code=409,
6873
+ detail="An account with this email already exists.",
6874
+ )
6875
+ except HTTPException:
6876
+ raise
6877
+ except Exception as auth_lookup_error:
6878
+ if not _is_auth_user_not_found_error(auth_lookup_error):
6879
+ logger.warning(
6880
+ "Auth duplicate-check failed for %s: %s", email, auth_lookup_error
6881
+ )
6882
+ warnings.append("Auth duplicate-check could not be completed; proceeding cautiously.")
6883
+
6884
+ try:
6885
+ created_auth_user = cast(Any, firebase_auth).create_user(
6886
+ email=email,
6887
+ password=temporary_password,
6888
+ display_name=name,
6889
+ )
6890
+ auth_uid = str(getattr(created_auth_user, "uid", "") or "").strip() or None
6891
+ except Exception as create_auth_error:
6892
+ error_text = str(create_auth_error).lower()
6893
+ if "email already exists" in error_text or "email_exists" in error_text:
6894
+ raise HTTPException(
6895
+ status_code=409,
6896
+ detail="An account with this email already exists.",
6897
+ )
6898
+ logger.error("Auth user creation failed for %s: %s", email, create_auth_error)
6899
+ raise HTTPException(status_code=500, detail="Failed to create authentication account.")
6900
+
6901
+ if not auth_uid:
6902
+ raise HTTPException(status_code=500, detail="Authentication account did not return a uid.")
6903
+
6904
+ firestore_client = cast(Any, firebase_firestore).client()
6905
+
6906
+ avatar_url = (
6907
+ f"https://ui-avatars.com/api/?name={urllib.parse.quote_plus(name)}&background=0d9488&color=fff"
6908
+ )
6909
+
6910
+ user_doc: Dict[str, Any] = {
6911
+ "name": name,
6912
+ "email": email,
6913
+ "role": "student",
6914
+ "status": "Active",
6915
+ "photo": avatar_url,
6916
+ "forcePasswordChange": True,
6917
+ "level": 1,
6918
+ "currentXP": 0,
6919
+ "totalXP": 0,
6920
+ "atRiskSubjects": [],
6921
+ "hasTakenDiagnostic": False,
6922
+ "adviserTeacherId": adviser_teacher_id,
6923
+ "createdAt": FIRESTORE_SERVER_TIMESTAMP,
6924
+ "updatedAt": FIRESTORE_SERVER_TIMESTAMP,
6925
+ }
6926
+ if lrn:
6927
+ user_doc["lrn"] = lrn
6928
+ if grade:
6929
+ user_doc["grade"] = grade
6930
+ if section:
6931
+ user_doc["section"] = section
6932
+ if class_section_id:
6933
+ user_doc["classSectionId"] = class_section_id
6934
+ if adviser_name:
6935
+ user_doc["adviserTeacherName"] = adviser_name
6936
+
6937
+ try:
6938
+ firestore_client.collection("users").document(auth_uid).set(user_doc, merge=True)
6939
+ except Exception as user_doc_error:
6940
+ logger.error(
6941
+ "Failed to write users/%s after Auth creation: %s", auth_uid, user_doc_error
6942
+ )
6943
+ warnings.append(
6944
+ "User profile write failed; the auth account exists but the dashboard may not see it yet."
6945
+ )
6946
+
6947
+ managed_doc: Dict[str, Any] = {
6948
+ "accountUid": auth_uid,
6949
+ "name": name,
6950
+ "email": email,
6951
+ "teacherId": adviser_teacher_id,
6952
+ "avatar": avatar_url,
6953
+ "riskLevel": "Low",
6954
+ "avgQuizScore": 0,
6955
+ "engagementScore": 0,
6956
+ "attendance": 0,
6957
+ "assignmentCompletion": 0,
6958
+ "weakestTopic": "",
6959
+ "struggles": [],
6960
+ "hasRegisteredAccount": True,
6961
+ "source": "both",
6962
+ "lastActive": None,
6963
+ "createdAt": FIRESTORE_SERVER_TIMESTAMP,
6964
+ "updatedAt": FIRESTORE_SERVER_TIMESTAMP,
6965
+ }
6966
+ if lrn:
6967
+ managed_doc["lrn"] = lrn
6968
+ if grade:
6969
+ managed_doc["grade"] = grade
6970
+ if section:
6971
+ managed_doc["section"] = section
6972
+ if class_section_id:
6973
+ managed_doc["classSectionId"] = class_section_id
6974
+ managed_doc["classroomId"] = class_section_id
6975
+ if school_year:
6976
+ managed_doc["schoolYear"] = school_year
6977
+
6978
+ try:
6979
+ firestore_client.collection("managedStudents").document(auth_uid).set(
6980
+ managed_doc, merge=True
6981
+ )
6982
+ except Exception as managed_error:
6983
+ logger.warning(
6984
+ "Failed to merge managedStudents/%s: %s", auth_uid, managed_error
6985
+ )
6986
+ warnings.append("Managed-student enrichment row write failed; will retry on next dashboard refresh.")
6987
+
6988
+ # Attach the new uid to the section's ownership roster (best-effort).
6989
+ if class_section_id:
6990
+ try:
6991
+ ownership_ref = firestore_client.collection("classSectionOwnership").document(class_section_id)
6992
+ ownership_snap = ownership_ref.get()
6993
+ ownership_payload: Dict[str, Any] = {
6994
+ "classSectionId": class_section_id,
6995
+ "ownerTeacherId": adviser_teacher_id,
6996
+ "updatedAt": FIRESTORE_SERVER_TIMESTAMP,
6997
+ }
6998
+ if grade:
6999
+ ownership_payload["grade"] = grade
7000
+ if section:
7001
+ ownership_payload["section"] = section
7002
+ if school_year:
7003
+ ownership_payload["schoolYear"] = school_year
7004
+ if adviser_name:
7005
+ ownership_payload["ownerTeacherName"] = adviser_name
7006
+
7007
+ if ownership_snap.exists:
7008
+ existing_uids: List[str] = list(
7009
+ (ownership_snap.to_dict() or {}).get("studentUids", [])
7010
+ )
7011
+ if auth_uid not in existing_uids:
7012
+ existing_uids.append(auth_uid)
7013
+ ownership_payload["studentUids"] = existing_uids
7014
+ ownership_ref.update(ownership_payload)
7015
+ else:
7016
+ ownership_payload["studentUids"] = [auth_uid]
7017
+ ownership_payload["createdAt"] = FIRESTORE_SERVER_TIMESTAMP
7018
+ ownership_ref.set(ownership_payload, merge=True)
7019
+ except Exception as ownership_error:
7020
+ logger.warning(
7021
+ "classSectionOwnership/%s update failed: %s", class_section_id, ownership_error
7022
+ )
7023
+ warnings.append("Section ownership roster could not be updated; this is non-fatal.")
7024
+
7025
+ _write_access_audit_log(
7026
+ request,
7027
+ action="teacher_create_student_account",
7028
+ status="success",
7029
+ metadata={
7030
+ "uid": auth_uid,
7031
+ "email": email,
7032
+ "adviserTeacherId": adviser_teacher_id,
7033
+ "classSectionId": class_section_id or None,
7034
+ },
7035
+ )
7036
+
7037
+ return CreateStudentAccountResponse(
7038
+ success=True,
7039
+ uid=auth_uid,
7040
+ email=email,
7041
+ message="Student account created.",
7042
+ warnings=warnings,
7043
+ )
7044
+ except HTTPException:
7045
+ _write_access_audit_log(
7046
+ request,
7047
+ action="teacher_create_student_account",
7048
+ status="failed",
7049
+ metadata={
7050
+ "email": email,
7051
+ "adviserTeacherId": adviser_teacher_id,
7052
+ },
7053
+ )
7054
+ raise
7055
+ except Exception as exc:
7056
+ logger.error("Unexpected teacher_create_student_account error: %s", exc)
7057
+ _write_access_audit_log(
7058
+ request,
7059
+ action="teacher_create_student_account",
7060
+ status="failed",
7061
+ metadata={
7062
+ "email": email,
7063
+ "adviserTeacherId": adviser_teacher_id,
7064
+ "errorCode": "unexpected_error",
7065
+ },
7066
+ )
7067
+ raise HTTPException(status_code=500, detail="Failed to create student account.")
7068
+
7069
+
7070
  @app.delete("/api/admin/users", response_model=AdminDeleteUserResponse)
7071
  async def delete_admin_user_account(
7072
  request: Request,