diff --git a/src/routes/accounts.py b/src/routes/accounts.py index 82729aac..264c9623 100644 --- a/src/routes/accounts.py +++ b/src/routes/accounts.py @@ -1,13 +1,24 @@ from datetime import datetime, timezone from typing import cast -from fastapi import APIRouter, Depends, status, HTTPException +from fastapi import ( + APIRouter, + Depends, + status, + HTTPException, + BackgroundTasks +) from sqlalchemy import select, delete from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload -from config import get_jwt_auth_manager, get_settings, BaseAppSettings, get_accounts_email_notificator +from config import ( + get_settings, + BaseAppSettings, + get_accounts_email_notificator +) +from security.dependencies import get_jwt_auth_manager from database import ( get_db, UserModel, @@ -67,7 +78,9 @@ ) async def register_user( user_data: UserRegistrationRequestSchema, + background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), + email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator), ) -> UserRegistrationResponseSchema: """ Endpoint for user registration. @@ -117,6 +130,13 @@ async def register_user( activation_token = ActivationTokenModel(user_id=new_user.id) db.add(activation_token) + activation_link = f"http://127.0.0.1/accounts/activate/?token={activation_token.token}" + + background_tasks.add_task( + email_sender.send_activation_email, + new_user.email, + activation_link + ) await db.commit() await db.refresh(new_user) @@ -163,7 +183,9 @@ async def register_user( ) async def activate_account( activation_data: UserActivationRequestSchema, + background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), + email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator), ) -> MessageResponseSchema: """ Endpoint to activate a user's account. @@ -213,12 +235,22 @@ async def activate_account( status_code=status.HTTP_400_BAD_REQUEST, detail="User account is already active." ) - - user.is_active = True - await db.delete(token_record) - await db.commit() - - return MessageResponseSchema(message="User account activated successfully.") + try: + user.is_active = True + await db.delete(token_record) + await db.commit() + background_tasks.add_task( + email_sender.send_activation_complete_email, + user.email + ) + except SQLAlchemyError as e: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to activate user account." + ) from e + else: + return MessageResponseSchema(message="User account activated successfully.") @router.post( @@ -232,8 +264,10 @@ async def activate_account( status_code=status.HTTP_200_OK, ) async def request_password_reset_token( - data: PasswordResetRequestSchema, + reset_request: PasswordResetRequestSchema, + background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), + email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator), ) -> MessageResponseSchema: """ Endpoint to request a password reset token. @@ -248,7 +282,7 @@ async def request_password_reset_token( Returns: MessageResponseSchema: A success message indicating that instructions will be sent. """ - stmt = select(UserModel).filter_by(email=data.email) + stmt = select(UserModel).filter_by(email=reset_request.email) result = await db.execute(stmt) user = result.scalars().first() @@ -258,14 +292,27 @@ async def request_password_reset_token( ) await db.execute(delete(PasswordResetTokenModel).where(PasswordResetTokenModel.user_id == user.id)) - - reset_token = PasswordResetTokenModel(user_id=cast(int, user.id)) - db.add(reset_token) - await db.commit() - - return MessageResponseSchema( - message="If you are registered, you will receive an email with instructions." - ) + try: + reset_token = PasswordResetTokenModel(user_id=cast(int, user.id)) + db.add(reset_token) + await db.commit() + if user and user.is_active: + reset_link = f"http://127.0.0.1/accounts/password-reset/complete/?token={reset_token.token}" + background_tasks.add_task( + email_sender.send_password_reset_email, + user.email, + reset_link + ) + except SQLAlchemyError as e: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create password reset token." + ) from e + else: + return MessageResponseSchema( + message="If a user with this email exists, a password reset link has been sent." + ) @router.post( @@ -312,8 +359,10 @@ async def request_password_reset_token( }, ) async def reset_password( - data: PasswordResetCompleteRequestSchema, + reset_data: PasswordResetCompleteRequestSchema, + background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), + email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator), ) -> MessageResponseSchema: """ Endpoint for resetting a user's password. @@ -334,7 +383,7 @@ async def reset_password( - 400 Bad Request if the email or token is invalid, or the token has expired. - 500 Internal Server Error if an error occurs during the password reset process. """ - stmt = select(UserModel).filter_by(email=data.email) + stmt = select(UserModel).filter_by(email=reset_data.email) result = await db.execute(stmt) user = result.scalars().first() if not user or not user.is_active: @@ -347,7 +396,7 @@ async def reset_password( result = await db.execute(stmt) token_record = result.scalars().first() - if not token_record or token_record.token != data.token: + if not token_record or token_record.token != reset_data.token: if token_record: await db.run_sync(lambda s: s.delete(token_record)) await db.commit() @@ -366,17 +415,24 @@ async def reset_password( ) try: - user.password = data.password + user.update_password(reset_data.password) await db.run_sync(lambda s: s.delete(token_record)) await db.commit() - except SQLAlchemyError: + login_link = "http://127.0.0.1/accounts/login/" + background_tasks.add_task( + email_sender.send_password_reset_complete_email, + user.email, + login_link + ) + + except SQLAlchemyError as e: await db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="An error occurred while resetting the password." - ) - - return MessageResponseSchema(message="Password reset successfully.") + detail="Failed to reset password." + ) from e + else: + return MessageResponseSchema(message="Password has been reset successfully.") @router.post( diff --git a/src/routes/profiles.py b/src/routes/profiles.py index 0b7c3420..106a81ec 100644 --- a/src/routes/profiles.py +++ b/src/routes/profiles.py @@ -1,5 +1,250 @@ -from fastapi import APIRouter +from datetime import date + +from fastapi import ( + APIRouter, + Depends, + status, + HTTPException, + UploadFile, + Form, + File +) +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import get_s3_storage_client +from database import get_db, UserModel, UserProfileModel, UserGroupEnum +from exceptions import S3FileUploadError +from schemas.profiles import ProfileResponseSchema +from security.dependencies import get_user +from storages import S3StorageInterface +from validation import ( + validate_name, + validate_image, + validate_gender, + validate_birth_date +) router = APIRouter() -# Write your code here + +def _validate_profile_input( + first_name: str, + last_name: str, + gender: str, + date_of_birth: date, + info: str, + avatar: UploadFile +) -> None: + """Validate all profile input fields.""" + try: + validate_name(first_name) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + try: + validate_name(last_name) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + if not info or not info.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Info cannot be empty or consist only of spaces." + ) + + try: + validate_gender(gender) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + try: + validate_birth_date(date_of_birth) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + try: + validate_image(avatar) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + +def _check_authorization( + current_user: UserModel, + user_id: int +) -> None: + """Check if user has permission to create profile.""" + is_admin = current_user.has_group(UserGroupEnum.ADMIN) + if not is_admin and current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to edit this profile." + ) + + +async def _verify_target_user( + user_id: int, + db: AsyncSession +) -> None: + """Verify target user exists and is active.""" + stmt = select(UserModel).where(UserModel.id == user_id) + result = await db.execute(stmt) + target_user = result.scalar_one_or_none() + + if not target_user or not target_user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or not active." + ) + + +async def _check_existing_profile( + user_id: int, + db: AsyncSession +) -> None: + """Check if profile already exists.""" + stmt = select(UserProfileModel).where( + UserProfileModel.user_id == user_id + ) + result = await db.execute(stmt) + existing_profile = result.scalar_one_or_none() + + if existing_profile: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User already has a profile." + ) + + +async def _upload_avatar( + user_id: int, + avatar: UploadFile, + s3_client: S3StorageInterface +) -> str: + """Upload avatar to S3 and return key.""" + avatar_key = f"avatars/{user_id}_avatar.jpg" + + try: + avatar.file.seek(0) + avatar_content = avatar.file.read() + await s3_client.upload_file(avatar_key, avatar_content) + except S3FileUploadError: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to upload avatar. Please try again later." + ) + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to upload avatar. Please try again later." + ) + + return avatar_key + + +async def _create_profile_in_db( + user_id: int, + first_name: str, + last_name: str, + gender: str, + date_of_birth: date, + info: str, + avatar_key: str, + db: AsyncSession +) -> UserProfileModel: + """Create profile in database.""" + new_profile = UserProfileModel( + user_id=user_id, + first_name=first_name.lower(), + last_name=last_name.lower(), + gender=gender, + date_of_birth=date_of_birth, + info=info, + avatar=avatar_key + ) + + db.add(new_profile) + + try: + await db.commit() + await db.refresh(new_profile) + except Exception: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create profile. Please try again later." + ) + + return new_profile + + +@router.post( + "/users/{user_id}/profile/", + response_model=ProfileResponseSchema, + status_code=status.HTTP_201_CREATED, + summary="Create user profile", + description="Create a user profile with avatar upload to S3 storage", +) +async def create_user_profile( + user_id: int, + first_name: str = Form(...), + last_name: str = Form(...), + gender: str = Form(...), + date_of_birth: date = Form(...), + info: str = Form(...), + avatar: UploadFile = File(...), + current_user: UserModel = Depends(get_user), + db: AsyncSession = Depends(get_db), + s3_client: S3StorageInterface = Depends(get_s3_storage_client), +) -> ProfileResponseSchema: + """ + Create a user profile with validation and avatar upload. + + Authorization Rules: + - A user can only create their own profile + - Admins can create profiles for any user + """ + _validate_profile_input( + first_name, last_name, gender, date_of_birth, info, avatar + ) + + _check_authorization(current_user, user_id) + + await _verify_target_user(user_id, db) + + await _check_existing_profile(user_id, db) + + avatar_key = await _upload_avatar(user_id, avatar, s3_client) + + new_profile = await _create_profile_in_db( + user_id, first_name, last_name, gender, + date_of_birth, info, avatar_key, db + ) + + avatar_url = await s3_client.get_file_url(avatar_key) + + return ProfileResponseSchema( + id=new_profile.id, + user_id=new_profile.user_id, + first_name=new_profile.first_name, + last_name=new_profile.last_name, + gender=new_profile.gender, + date_of_birth=new_profile.date_of_birth, + info=new_profile.info, + avatar=avatar_url + ) diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py index 24b866c3..529a8ffc 100644 --- a/src/schemas/__init__.py +++ b/src/schemas/__init__.py @@ -17,3 +17,7 @@ TokenRefreshRequestSchema, TokenRefreshResponseSchema ) +from schemas.profiles import ( + ProfileCreateSchema, + ProfileResponseSchema, +) diff --git a/src/schemas/profiles.py b/src/schemas/profiles.py index adbcbcee..28207156 100644 --- a/src/schemas/profiles.py +++ b/src/schemas/profiles.py @@ -1,6 +1,6 @@ from datetime import date -from fastapi import UploadFile, Form, File, HTTPException +from fastapi import UploadFile, Form, File from pydantic import BaseModel, field_validator, HttpUrl from validation import ( @@ -10,4 +10,73 @@ validate_birth_date ) -# Write your code here + +class ProfileCreateSchema(BaseModel): + """Schema for creating a user profile with form data and file upload.""" + + first_name: str + last_name: str + gender: str + date_of_birth: date + info: str + avatar: UploadFile + + @field_validator("first_name") + @classmethod + def validate_first_name(cls, v: str) -> str: + """Validate that first name contains only English letters.""" + validate_name(v) + return v.lower() + + @field_validator("last_name") + @classmethod + def validate_last_name(cls, v: str) -> str: + """Validate that last name contains only English letters.""" + validate_name(v) + return v.lower() + + @field_validator("info") + @classmethod + def validate_info(cls, v: str) -> str: + """Validate that info is not empty or whitespace only.""" + if not v or not v.strip(): + raise ValueError("Info cannot be empty or consist only of spaces.") + return v + + @field_validator("gender") + @classmethod + def validate_gender_field(cls, v: str) -> str: + """Validate that gender is a valid option.""" + validate_gender(v) + return v + + @field_validator("date_of_birth") + @classmethod + def validate_date_of_birth_field(cls, v: date) -> date: + """Validate that date of birth meets age requirements.""" + validate_birth_date(v) + return v + + @field_validator("avatar") + @classmethod + def validate_avatar_field(cls, v: UploadFile) -> UploadFile: + """Validate that avatar is a valid image file.""" + validate_image(v) + return v + + +class ProfileResponseSchema(BaseModel): + """Schema for profile response.""" + + id: int + user_id: int + first_name: str + last_name: str + gender: str + date_of_birth: date + info: str + avatar: str + + model_config = { + "from_attributes": True + } diff --git a/src/security/dependencies.py b/src/security/dependencies.py new file mode 100644 index 00000000..682cbf66 --- /dev/null +++ b/src/security/dependencies.py @@ -0,0 +1,92 @@ +from fastapi import Depends, HTTPException, status, Header +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import get_settings, BaseAppSettings +from database import get_db, UserModel +from exceptions import BaseSecurityError +from security.interfaces import JWTAuthManagerInterface +from security.token_manager import JWTAuthManager + + +def get_jwt_auth_manager( + settings: BaseAppSettings = Depends(get_settings) +) -> JWTAuthManagerInterface: + """ + Create and return a JWT authentication manager instance. + + This function uses the provided application settings to instantiate a JWTAuthManager, + which implements the JWTAuthManagerInterface. The manager is configured with secret + keys for access and refresh tokens as well as the JWT signing algorithm specified + in the settings. + """ + return JWTAuthManager( + secret_key_access=settings.SECRET_KEY_ACCESS, + secret_key_refresh=settings.SECRET_KEY_REFRESH, + algorithm=settings.JWT_SIGNING_ALGORITHM + ) + + +async def get_user( + authorization: str = Header(None), + jwt_manager: JWTAuthManagerInterface = Depends(get_jwt_auth_manager), + db: AsyncSession = Depends(get_db), +) -> UserModel: + """ + Dependency to extract and validate the current user from the Authorization header. + + This dependency performs complete JWT-based authentication: + 1. Validates the Authorization header is present + 2. Validates the format is "Bearer " + 3. Decodes the JWT access token + 4. Retrieves the user from the database using the user_id from the token + 5. Verifies the user exists and is active + """ + + if not authorization: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authorization header is missing" + ) + + parts = authorization.split() + if len(parts) != 2 or parts[0].lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Authorization header format. Expected 'Bearer '" + ) + + token = parts[1] + + try: + payload = jwt_manager.decode_access_token(token) + except BaseSecurityError as e: + if "expired" in str(e).lower(): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired." + ) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token." + ) + + user_id = payload.get("user_id") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload." + ) + + stmt = select(UserModel).where(UserModel.id == user_id) + result = await db.execute(stmt) + user = result.scalar_one_or_none() + + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or not active." + ) + + return user