Spaces:
Configuration error
Configuration error
| import os | |
| import uuid | |
| import shutil | |
| from datetime import datetime, timezone, date, timedelta | |
| from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status | |
| from prisma.models import Student | |
| from app.api.dependencies import get_current_student | |
| from app.core.config import settings | |
| from app.core.logging_config import get_logger | |
| from app.core.security import create_access_token | |
| from app.db.client import db | |
| from app.repositories.leave_repo import LeaveRepository | |
| from app.schemas.attendance import AttendanceMarkResponse, AttendanceAnalyzeResponse | |
| from app.schemas.student import StudentAttendanceHistoryResponse, StudentClassResponse | |
| from app.schemas.leave import LeaveRequestResponse, LeaveRequestListResponse | |
| from app.services.attendance_service import AttendanceService, AttendanceSubmission | |
| from app.services.student_service import StudentService | |
| from app.services.gamification_service import GamificationService | |
| logger = get_logger("app.api.student") | |
| router = APIRouter(prefix="/student", tags=["Student Features"]) | |
| _MAX_IMAGE_SIZE = 5 * 1024 * 1024 | |
| _VALID_IMAGE_TYPES = {"image/jpeg", "image/png", "image/jpg"} | |
| def _save_uploaded_image(upload_file: UploadFile, folder: str) -> str: | |
| target_dir = os.path.join(settings.UPLOAD_DIR, folder) | |
| os.makedirs(target_dir, exist_ok=True) | |
| ext = os.path.splitext(upload_file.filename or "")[1] or ".jpg" | |
| target_path = os.path.join(target_dir, f"{uuid.uuid4()}{ext}") | |
| with open(target_path, "wb") as buffer: | |
| shutil.copyfileobj(upload_file.file, buffer) | |
| return target_path | |
| def _validate_image(image: UploadFile) -> None: | |
| if image.content_type not in _VALID_IMAGE_TYPES: | |
| raise HTTPException( | |
| status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, | |
| detail="Unsupported media type. Upload must be a valid JPEG or PNG image.", | |
| ) | |
| if image.size is not None and image.size > _MAX_IMAGE_SIZE: | |
| raise HTTPException( | |
| status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, | |
| detail="Payload too large. Uploaded image cannot exceed 5MB.", | |
| ) | |
| def _make_leave_response(leave, student_name: str, enrollment_number: str = None) -> LeaveRequestResponse: | |
| return LeaveRequestResponse( | |
| id=leave.id, | |
| student_id=leave.studentId, | |
| student_name=student_name or "Unknown", | |
| enrollment_number=enrollment_number or (leave.student.enrollmentNumber if leave.student else "N/A"), | |
| start_date=leave.startDate, | |
| end_date=leave.endDate, | |
| reason=leave.reason, | |
| document_url=leave.documentUrl, | |
| status=leave.status, | |
| approved_by=leave.approvedBy, | |
| approver_note=leave.approverNote, | |
| created_at=leave.createdAt, | |
| updated_at=leave.updatedAt, | |
| ) | |
| def _student_name(student: Student) -> str: | |
| if not student: | |
| return "Unknown" | |
| return f"{student.firstName or ''} {student.lastName or ''}".strip() | |
| async def mark_attendance( | |
| session_id: str = Form(...), | |
| latitude: float = Form(...), | |
| longitude: float = Form(...), | |
| accuracy: float = Form(...), | |
| image: UploadFile | None = File(None), | |
| student: Student = Depends(get_current_student), | |
| attendance_service: AttendanceService = Depends(), | |
| ) -> AttendanceMarkResponse: | |
| image_path = None | |
| if image: | |
| _validate_image(image) | |
| image_path = _save_uploaded_image(image, "attendance") | |
| submission = AttendanceSubmission( | |
| student_id=student.id, session_id=session_id, | |
| latitude=latitude, longitude=longitude, accuracy=accuracy, image_path=image_path, | |
| ) | |
| try: | |
| attendance = await attendance_service.mark_attendance(submission) | |
| return AttendanceMarkResponse.model_validate(attendance) | |
| except ValueError as err: | |
| if image_path and os.path.exists(image_path): | |
| os.remove(image_path) | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err)) | |
| async def analyze_attendance( | |
| session_id: str = Form(...), | |
| latitude: float = Form(...), | |
| longitude: float = Form(...), | |
| accuracy: float = Form(...), | |
| image: UploadFile | None = File(None), | |
| student: Student = Depends(get_current_student), | |
| attendance_service: AttendanceService = Depends(), | |
| ) -> AttendanceAnalyzeResponse: | |
| """Run AI scoring and validation without saving the attendance record. | |
| Returns scores and a short-lived review_token (5 min) that the student | |
| can use to confirm submission via POST /attendance/confirm. | |
| """ | |
| image_path = None | |
| if image: | |
| _validate_image(image) | |
| image_path = _save_uploaded_image(image, "attendance") | |
| submission = AttendanceSubmission( | |
| student_id=student.id, session_id=session_id, | |
| latitude=latitude, longitude=longitude, accuracy=accuracy, image_path=image_path, | |
| ) | |
| try: | |
| result = await attendance_service.analyze_attendance(submission) | |
| return AttendanceAnalyzeResponse(**result) | |
| except ValueError as err: | |
| if image_path and os.path.exists(image_path): | |
| os.remove(image_path) | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err)) | |
| async def confirm_attendance( | |
| review_token: str = Form(...), | |
| student: Student = Depends(get_current_student), | |
| attendance_service: AttendanceService = Depends(), | |
| ) -> AttendanceMarkResponse: | |
| """Confirm a previously analyzed attendance submission. | |
| Accepts the review_token returned by POST /attendance/analyze and saves | |
| the attendance record without re-running AI inference. | |
| """ | |
| try: | |
| attendance = await attendance_service.confirm_attendance(student.id, review_token) | |
| return AttendanceMarkResponse.model_validate(attendance) | |
| except ValueError as err: | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err)) | |
| async def register_face( | |
| image: UploadFile = File(...), | |
| student: Student = Depends(get_current_student), | |
| attendance_service: AttendanceService = Depends(), | |
| ) -> dict: | |
| _validate_image(image) | |
| image_path = _save_uploaded_image(image, "registration") | |
| try: | |
| success = await attendance_service.register_face(student.id, image_path) | |
| if not success: | |
| raise ValueError("Could not extract a valid face from the image.") | |
| return {"status": "success", "message": "Face embedding registered successfully."} | |
| except ValueError as err: | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err)) | |
| finally: | |
| if os.path.exists(image_path): | |
| os.remove(image_path) | |
| async def get_my_attendance( | |
| student: Student = Depends(get_current_student), | |
| student_service: StudentService = Depends(), | |
| ) -> StudentAttendanceHistoryResponse: | |
| return await student_service.get_student_attendance_history(student.userId) | |
| async def get_my_classes( | |
| student: Student = Depends(get_current_student), | |
| student_service: StudentService = Depends(), | |
| ) -> list[StudentClassResponse]: | |
| return await student_service.get_student_classes(student.userId) | |
| async def register_fcm_token( | |
| payload: dict, | |
| student: Student = Depends(get_current_student), | |
| ) -> dict: | |
| token = payload.get("token") | |
| if not token: | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="FCM token is required.") | |
| await db.student.update(where={"id": student.id}, data={"fcmToken": token}) | |
| return {"status": "success", "message": "FCM token registered."} | |
| async def submit_flagged_note( | |
| attendance_id: str, | |
| payload: dict, | |
| student: Student = Depends(get_current_student), | |
| ) -> dict: | |
| note = payload.get("note", "").strip() | |
| if not note: | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Note cannot be empty.") | |
| if len(note) > 500: | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Note cannot exceed 500 characters.") | |
| record = await db.attendance.find_unique(where={"id": attendance_id}) | |
| if not record or record.studentId != student.id: | |
| raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attendance record not found.") | |
| if record.status != "Flagged": | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Notes can only be added to flagged records.") | |
| await db.attendance.update(where={"id": attendance_id}, data={"studentNote": note}) | |
| return {"status": "success", "message": "Note submitted successfully."} | |
| async def get_my_leaves( | |
| student: Student = Depends(get_current_student), | |
| leave_repo: LeaveRepository = Depends(), | |
| ) -> LeaveRequestListResponse: | |
| leaves = await leave_repo.get_by_student_id(student.id) | |
| leave_responses = [_make_leave_response(req, _student_name(req.student)) for req in leaves] | |
| return LeaveRequestListResponse( | |
| leaves=leave_responses, | |
| total=len(leave_responses), | |
| pending=sum(1 for req in leaves if req.status == "PENDING"), | |
| approved=sum(1 for req in leaves if req.status == "APPROVED"), | |
| rejected=sum(1 for req in leaves if req.status == "REJECTED"), | |
| ) | |
| async def create_leave_request( | |
| start_date: str = Form(...), | |
| end_date: str = Form(...), | |
| reason: str = Form(...), | |
| document: UploadFile = File(None), | |
| student: Student = Depends(get_current_student), | |
| leave_repo: LeaveRepository = Depends(), | |
| ) -> LeaveRequestResponse: | |
| try: | |
| s_date = date.fromisoformat(start_date) | |
| e_date = date.fromisoformat(end_date) | |
| except ValueError: | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date format. Use YYYY-MM-DD.") | |
| if e_date < s_date: | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="End date must be after or equal to start date.") | |
| if len(reason) < 10 or len(reason) > 500: | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reason must be between 10 and 500 characters.") | |
| document_url = None | |
| if document and document.filename: | |
| try: | |
| upload_dir = "static/leaves" | |
| os.makedirs(upload_dir, exist_ok=True) | |
| ext = os.path.splitext(document.filename)[1] | |
| filename = f"leave_{student.id}_{int(datetime.now().timestamp())}{ext}" | |
| file_path = os.path.join(upload_dir, filename) | |
| with open(file_path, "wb") as f: | |
| f.write(await document.read()) | |
| document_url = f"/static/leaves/{filename}" | |
| except Exception: | |
| raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not save leave document.") | |
| leave = await leave_repo.create({ | |
| "studentId": student.id, | |
| "startDate": datetime.combine(s_date, datetime.min.time()).replace(tzinfo=timezone.utc), | |
| "endDate": datetime.combine(e_date, datetime.max.time()).replace(tzinfo=timezone.utc), | |
| "reason": reason, | |
| "documentUrl": document_url, | |
| "status": "PENDING", | |
| }) | |
| return _make_leave_response(leave, _student_name(student), student.enrollmentNumber) | |
| async def get_smart_pass(student: Student = Depends(get_current_student)) -> dict: | |
| qr_token = create_access_token( | |
| subject=student.userId, | |
| role="STUDENT", | |
| expires_delta=timedelta(seconds=30), | |
| extra_data={"student_id": student.id, "enrollment_number": student.enrollmentNumber, "type": "smart_pass"}, | |
| ) | |
| return { | |
| "qr_token": qr_token, | |
| "expires_at": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), | |
| "student_name": _student_name(student), | |
| "enrollment_number": student.enrollmentNumber, | |
| } | |
| async def get_my_stats(student: Student = Depends(get_current_student)) -> dict: | |
| return await GamificationService().get_student_stats(student.id) | |
| async def get_leaderboard(student: Student = Depends(get_current_student)) -> dict: | |
| return await GamificationService().get_leaderboard(student.id) | |