Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
56f4e10
1
Parent(s): 35426f1
🚀 Auto-deploy backend from GitHub (b157bce)
Browse files
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,
|