Skip to content

Commit 33a6a70

Browse files
committed
reset the password feature
1 parent 6a93956 commit 33a6a70

22 files changed

Lines changed: 1321 additions & 84 deletions

File tree

151 Bytes
Binary file not shown.
5.5 KB
Binary file not shown.
3.55 KB
Binary file not shown.
2.83 KB
Binary file not shown.
198 Bytes
Binary file not shown.

Backend/app/api/routes/auth.py

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
22
from fastapi import APIRouter, Depends, HTTPException, status
33
from fastapi.security import HTTPBearer
44
from sqlalchemy.orm import Session
5-
from app.schemas.user import UserLogin, Token, UserCreate, UserOut
5+
from app.schemas.user import (
6+
UserLogin,
7+
Token,
8+
UserCreate,
9+
UserOut,
10+
PasswordResetRequest,
11+
PasswordResetVerify,
12+
PasswordResetConfirm
13+
)
614
from app.core.auth import (
715
verify_password,
816
get_password_hash,
@@ -16,10 +24,13 @@
1624
from app.core.email import (
1725
send_verification_email,
1826
send_welcome_email,
27+
send_reset_password_email,
1928
generate_verification_code,
2029
generate_verification_token,
2130
get_verification_code_expiry,
22-
get_verification_token_expiry
31+
get_verification_token_expiry,
32+
get_reset_code_expiry,
33+
get_reset_token_expiry
2334
)
2435
from app.database import get_db
2536
from app.models.user import User
@@ -352,4 +363,163 @@ def resend_verification_email(email: str, db: Session = Depends(get_db)):
352363
return {
353364
"message": "Verification email sent successfully",
354365
"email": user.email
366+
}
367+
368+
369+
@router.post("/password-reset/request")
370+
def request_password_reset(request: PasswordResetRequest, db: Session = Depends(get_db)):
371+
"""
372+
Request password reset by email. Sends reset code and magic link.
373+
Returns success even if email doesn't exist (security best practice).
374+
"""
375+
# Find user by email
376+
user = db.query(User).filter(User.email == request.email).first()
377+
378+
# Always return success (don't reveal if email exists)
379+
if not user:
380+
return {
381+
"message": "If an account exists with this email, you will receive a password reset link.",
382+
"email": request.email
383+
}
384+
385+
# Generate reset code and token
386+
reset_code = generate_verification_code() # Reuse existing function (6 digits)
387+
reset_token = generate_verification_token() # Reuse existing function
388+
389+
# Update user with reset data
390+
user.reset_code = reset_code
391+
user.reset_code_expires = get_reset_code_expiry() # 15 minutes
392+
user.reset_token = reset_token
393+
user.reset_token_expires = get_reset_token_expiry() # 15 minutes
394+
395+
db.commit()
396+
397+
# Send reset email
398+
send_reset_password_email(
399+
to_email=user.email,
400+
username=user.username or user.id,
401+
code=reset_code,
402+
token=reset_token
403+
)
404+
405+
return {
406+
"message": "If an account exists with this email, you will receive a password reset link.",
407+
"email": request.email
408+
}
409+
410+
411+
@router.post("/password-reset/verify-code")
412+
def verify_reset_code(request: PasswordResetVerify, db: Session = Depends(get_db)):
413+
"""
414+
Verify that the reset code is valid without resetting password.
415+
This allows the frontend to show the new password form.
416+
"""
417+
user = db.query(User).filter(User.email == request.email).first()
418+
419+
if not user:
420+
raise HTTPException(
421+
status_code=status.HTTP_404_NOT_FOUND,
422+
detail="User not found"
423+
)
424+
425+
# Check if code matches
426+
if user.reset_code != request.code:
427+
raise HTTPException(
428+
status_code=status.HTTP_400_BAD_REQUEST,
429+
detail="Invalid reset code"
430+
)
431+
432+
# Check if code has expired
433+
if user.reset_code_expires and user.reset_code_expires < datetime.utcnow():
434+
raise HTTPException(
435+
status_code=status.HTTP_400_BAD_REQUEST,
436+
detail="Reset code has expired. Please request a new one."
437+
)
438+
439+
return {
440+
"message": "Code verified successfully",
441+
"email": user.email
442+
}
443+
444+
445+
@router.post("/password-reset/confirm")
446+
def reset_password_with_code(request: PasswordResetConfirm, db: Session = Depends(get_db)):
447+
"""
448+
Reset password using verified code and new password.
449+
"""
450+
user = db.query(User).filter(User.email == request.email).first()
451+
452+
if not user:
453+
raise HTTPException(
454+
status_code=status.HTTP_404_NOT_FOUND,
455+
detail="User not found"
456+
)
457+
458+
# Check if code matches
459+
if user.reset_code != request.code:
460+
raise HTTPException(
461+
status_code=status.HTTP_400_BAD_REQUEST,
462+
detail="Invalid reset code"
463+
)
464+
465+
# Check if code has expired
466+
if user.reset_code_expires and user.reset_code_expires < datetime.utcnow():
467+
raise HTTPException(
468+
status_code=status.HTTP_400_BAD_REQUEST,
469+
detail="Reset code has expired. Please request a new one."
470+
)
471+
472+
# Validate new password
473+
if len(request.new_password) < 6:
474+
raise HTTPException(
475+
status_code=status.HTTP_400_BAD_REQUEST,
476+
detail="Password must be at least 6 characters long"
477+
)
478+
479+
# Update password
480+
user.hashed_password = get_password_hash(request.new_password)
481+
482+
# Clear reset fields
483+
user.reset_code = None
484+
user.reset_code_expires = None
485+
user.reset_token = None
486+
user.reset_token_expires = None
487+
488+
db.commit()
489+
490+
return {
491+
"message": "Password reset successfully. You can now login with your new password.",
492+
"user": {
493+
"id": user.id,
494+
"email": user.email,
495+
"username": user.username
496+
}
497+
}
498+
499+
500+
@router.get("/password-reset/verify-token")
501+
def verify_reset_token(token: str, db: Session = Depends(get_db)):
502+
"""
503+
Verify reset token from magic link.
504+
Returns user email if valid, for the frontend to show password reset form.
505+
"""
506+
user = db.query(User).filter(User.reset_token == token).first()
507+
508+
if not user:
509+
raise HTTPException(
510+
status_code=status.HTTP_404_NOT_FOUND,
511+
detail="Invalid reset link"
512+
)
513+
514+
# Check if token has expired
515+
if user.reset_token_expires and user.reset_token_expires < datetime.utcnow():
516+
raise HTTPException(
517+
status_code=status.HTTP_400_BAD_REQUEST,
518+
detail="Reset link has expired. Please request a new one."
519+
)
520+
521+
return {
522+
"message": "Reset link is valid",
523+
"email": user.email,
524+
"code": user.reset_code # Return code so frontend can use it for password reset
355525
}

Backend/app/api/routes/feedback.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from fastapi import APIRouter, Depends, HTTPException
2+
from sqlalchemy.orm import Session
3+
from uuid import UUID
4+
import logging
5+
6+
from app.database import get_db
7+
from app.models.ai_feedback import AIFeedback
8+
from app.models.student_answer import StudentAnswer
9+
10+
router = APIRouter()
11+
logger = logging.getLogger(__name__)
12+
13+
14+
@router.get("/{feedback_id}")
15+
def get_ai_feedback_by_id(
16+
feedback_id: UUID,
17+
db: Session = Depends(get_db)
18+
):
19+
"""
20+
Get AI feedback by ID.
21+
Returns the feedback details including feedback text, score, question_id, and metadata.
22+
Used by the feedback critiques page to display feedback details.
23+
"""
24+
feedback = db.query(AIFeedback).filter(AIFeedback.id == feedback_id).first()
25+
26+
if not feedback:
27+
raise HTTPException(status_code=404, detail="Feedback not found")
28+
29+
# Get the associated answer to include question_id
30+
answer = db.query(StudentAnswer).filter(StudentAnswer.id == feedback.answer_id).first()
31+
32+
if not answer:
33+
raise HTTPException(status_code=404, detail="Associated answer not found")
34+
35+
# Build response with all necessary fields
36+
data = feedback.feedback_data or {}
37+
38+
return {
39+
"id": str(feedback.id),
40+
"answer_id": str(feedback.answer_id),
41+
"question_id": str(answer.question_id),
42+
"feedback_text": data.get("explanation", "") or data.get("feedback", ""),
43+
"is_correct": feedback.is_correct,
44+
"score": feedback.score,
45+
"correctness_score": feedback.score,
46+
"points_earned": feedback.points_earned,
47+
"points_possible": feedback.points_possible,
48+
"criterion_scores": feedback.criterion_scores,
49+
"explanation": data.get("explanation", ""),
50+
"improvement_hint": data.get("improvement_hint"),
51+
"concept_explanation": data.get("concept_explanation"),
52+
"strengths": data.get("strengths"),
53+
"weaknesses": data.get("weaknesses"),
54+
"selected_option": data.get("selected_option"),
55+
"correct_option": data.get("correct_option"),
56+
"available_options": data.get("available_options"),
57+
"grading_details": data.get("grading_details"),
58+
"selected_options": data.get("selected_options"),
59+
"sub_results": data.get("sub_results"),
60+
"has_course_materials": data.get("used_rag", False),
61+
"generated_at": feedback.generated_at.isoformat() if feedback.generated_at else None
62+
}

0 commit comments

Comments
 (0)