Spaces:
Running
Running
| from fastapi import APIRouter, HTTPException, Depends, status | |
| from datetime import datetime, timedelta | |
| from app.models.user import UserCreate, UserLogin, User, Token, UserResponse, UserUpdate | |
| from pydantic import BaseModel, model_validator | |
| from typing import Optional | |
| from app.core.auth import ( | |
| get_password_hash, | |
| verify_password, | |
| authenticate_user, | |
| create_access_token, | |
| get_user_by_email, | |
| get_current_active_user, | |
| create_user_response, | |
| ACCESS_TOKEN_EXPIRE_MINUTES | |
| ) | |
| from app.core.database import get_database | |
| import logging | |
| logger = logging.getLogger(__name__) | |
| class MessageResponse(BaseModel): | |
| message: str | |
| class ChangePasswordRequest(BaseModel): | |
| current_password: str | |
| new_password: str | |
| def passwords_must_differ(self): | |
| if self.current_password == self.new_password: | |
| raise ValueError("New password must be different from the current password") | |
| return self | |
| class UpdateProfileRequest(BaseModel): | |
| first_name: Optional[str] = None | |
| last_name: Optional[str] = None | |
| def at_least_one_field(self): | |
| if self.first_name is not None: | |
| self.first_name = self.first_name.strip() or None | |
| if self.last_name is not None: | |
| self.last_name = self.last_name.strip() or None | |
| if self.first_name is None and self.last_name is None: | |
| raise ValueError("At least one field must be provided") | |
| return self | |
| class DeleteAccountRequest(BaseModel): | |
| password: str | |
| router = APIRouter() | |
| async def signup(user_data: UserCreate): | |
| """ | |
| Register a new user and return an access token. | |
| @param user_data: UserCreate with name, email, password, and optional academic fields | |
| @return: Token containing a JWT access token and the created UserResponse | |
| """ | |
| try: | |
| db = get_database() | |
| # Check if user already exists | |
| existing_user = await get_user_by_email(user_data.email) | |
| if existing_user: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="Email already registered" | |
| ) | |
| # Create new user | |
| hashed_password = get_password_hash(user_data.password) | |
| user = User( | |
| firstName=user_data.firstName, | |
| lastName=user_data.lastName, | |
| email=user_data.email, | |
| hashed_password=hashed_password, | |
| academicStage=user_data.academicStage, | |
| researchArea=user_data.researchArea, | |
| created_at=datetime.utcnow(), | |
| is_active=True | |
| ) | |
| # Insert user into database | |
| result = await db.users.insert_one(user.dict(by_alias=True)) | |
| user.id = result.inserted_id | |
| profile_seed: dict = { | |
| "user_id": user.id, | |
| "updated_at": datetime.utcnow(), | |
| } | |
| if user_data.academicStage: | |
| profile_seed["knowledge_level"] = user_data.academicStage | |
| if user_data.researchArea: | |
| profile_seed["timezone"] = user_data.researchArea | |
| await db.user_profiles.update_one( | |
| {"user_id": user.id}, | |
| {"$set": profile_seed}, | |
| upsert=True, | |
| ) | |
| # Create access token | |
| access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) | |
| access_token = create_access_token( | |
| data={"sub": str(user.id)}, | |
| expires_delta=access_token_expires | |
| ) | |
| return Token( | |
| access_token=access_token, | |
| token_type="bearer", | |
| user=create_user_response(user) | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error during signup: {e}") | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Could not create user account" | |
| ) | |
| async def login(user_credentials: UserLogin): | |
| """ | |
| Authenticate a user and return an access token. | |
| @param user_credentials: UserLogin with email and password | |
| @return: Token containing a JWT access token and the authenticated UserResponse | |
| """ | |
| try: | |
| # Authenticate user | |
| user = await authenticate_user(user_credentials.email, user_credentials.password) | |
| if not user: | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Incorrect email or password", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| # Update last login time | |
| db = get_database() | |
| await db.users.update_one( | |
| {"_id": user.id}, | |
| {"$set": {"last_login": datetime.utcnow()}} | |
| ) | |
| user.last_login = datetime.utcnow() | |
| # Create access token | |
| access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) | |
| access_token = create_access_token( | |
| data={"sub": str(user.id)}, | |
| expires_delta=access_token_expires | |
| ) | |
| return Token( | |
| access_token=access_token, | |
| token_type="bearer", | |
| user=create_user_response(user) | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error during login: {e}") | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Login failed" | |
| ) | |
| async def get_current_user_profile(current_user: User = Depends(get_current_active_user)): | |
| """ | |
| Retrieve the profile of the currently authenticated user. | |
| @param current_user: Authenticated user from dependency injection | |
| @return: UserResponse with the user's profile information | |
| """ | |
| return create_user_response(current_user) | |
| async def logout(): | |
| """ | |
| Log out the current user (client should discard the token). | |
| @return: MessageResponse with a confirmation message | |
| """ | |
| return MessageResponse(message="Successfully logged out") | |
| async def verify_token(current_user: User = Depends(get_current_active_user)): | |
| """ | |
| Validate the caller's JWT and return their profile. | |
| @param current_user: Authenticated user from dependency injection | |
| @return: UserResponse with the user's profile information | |
| """ | |
| return create_user_response(current_user) | |
| async def change_password( | |
| body: ChangePasswordRequest, | |
| current_user: User = Depends(get_current_active_user), | |
| ): | |
| """ | |
| Change the authenticated user's password. | |
| @param body: ChangePasswordRequest with the current and new passwords | |
| @param current_user: Authenticated user from dependency injection | |
| @return: MessageResponse with a confirmation message | |
| """ | |
| try: | |
| if not verify_password(body.current_password, current_user.hashed_password): | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="Current password is incorrect", | |
| ) | |
| if len(body.new_password) < 8: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="New password must be at least 8 characters", | |
| ) | |
| db = get_database() | |
| await db.users.update_one( | |
| {"_id": current_user.id}, | |
| {"$set": {"hashed_password": get_password_hash(body.new_password)}}, | |
| ) | |
| return MessageResponse(message="Password changed successfully") | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error during password change: {e}") | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Could not change password" | |
| ) | |
| async def update_profile( | |
| body: UserUpdate, | |
| current_user: User = Depends(get_current_active_user), | |
| ): | |
| """Update account fields (name, email, avatar, signup preferences).""" | |
| try: | |
| db = get_database() | |
| updates = {k: v for k, v in body.dict().items() if v is not None} | |
| if body.firstName is not None: | |
| updates["firstName"] = body.firstName.strip() | |
| if body.lastName is not None: | |
| updates["lastName"] = body.lastName.strip() | |
| if not updates: | |
| return create_user_response(current_user) | |
| if "email" in updates and updates["email"] != current_user.email: | |
| existing = await get_user_by_email(updates["email"]) | |
| if existing: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="Email already in use", | |
| ) | |
| await db.users.update_one({"_id": current_user.id}, {"$set": updates}) | |
| profile_sync: dict = {"updated_at": datetime.utcnow()} | |
| if body.academicStage is not None: | |
| profile_sync["knowledge_level"] = body.academicStage | |
| if body.researchArea is not None: | |
| profile_sync["timezone"] = body.researchArea | |
| if len(profile_sync) > 1: | |
| await db.user_profiles.update_one( | |
| {"user_id": current_user.id}, | |
| {"$set": profile_sync, "$setOnInsert": {"user_id": current_user.id}}, | |
| upsert=True, | |
| ) | |
| updated_user = await db.users.find_one({"_id": current_user.id}) | |
| return create_user_response(User(**updated_user)) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error during profile update: {e}") | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Could not update profile" | |
| ) | |
| async def delete_account( | |
| body: DeleteAccountRequest, | |
| current_user: User = Depends(get_current_active_user), | |
| ): | |
| """ | |
| Permanently delete the authenticated user's account, all chat sessions, and all PhD Canvas data. | |
| @param body: DeleteAccountRequest with the user's password for confirmation | |
| @param current_user: Authenticated user from dependency injection | |
| @return: MessageResponse with a confirmation message | |
| """ | |
| try: | |
| if not verify_password(body.password, current_user.hashed_password): | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="Incorrect password", | |
| ) | |
| db = get_database() | |
| uid = current_user.id | |
| await db.chat_sessions.delete_many({"user_id": uid}) | |
| await db.phd_canvases.delete_many({"user_id": uid}) | |
| await db.users.delete_one({"_id": uid}) | |
| return MessageResponse(message="Account deleted") | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error during account deletion: {e}") | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Could not delete account" | |
| ) |