diff --git a/src/routes/accounts.py b/src/routes/accounts.py index 82729aac..ec7946b0 100644 --- a/src/routes/accounts.py +++ b/src/routes/accounts.py @@ -1,7 +1,7 @@ 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 @@ -67,7 +67,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. @@ -120,6 +122,15 @@ async def register_user( await db.commit() await db.refresh(new_user) + + activation_link = f"http://127.0.0.1/accounts/activate?token={activation_token.token}&email={new_user.email}" + + background_tasks.add_task( + email_sender.send_activation_email, + str(new_user.email), + activation_link + ) + except SQLAlchemyError as e: await db.rollback() raise HTTPException( @@ -163,7 +174,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. @@ -218,6 +231,14 @@ async def activate_account( await db.delete(token_record) await db.commit() + login_link = "http://127.0.0.1/accounts/login/" + + background_tasks.add_task( + email_sender.send_activation_complete_email, + str(activation_data.email), + login_link + ) + return MessageResponseSchema(message="User account activated successfully.") @@ -233,7 +254,9 @@ async def activate_account( ) async def request_password_reset_token( data: 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. @@ -263,6 +286,14 @@ async def request_password_reset_token( db.add(reset_token) await db.commit() + reset_link = f"http://127.0.0.1/accounts/password-reset?token={reset_token.token}&email={data.email}" + + background_tasks.add_task( + email_sender.send_password_reset_email, + str(data.email), + reset_link + ) + return MessageResponseSchema( message="If you are registered, you will receive an email with instructions." ) @@ -313,7 +344,9 @@ async def request_password_reset_token( ) async def reset_password( 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. @@ -376,6 +409,14 @@ async def reset_password( detail="An error occurred while resetting the password." ) + login_link = "http://127.0.0.1/accounts/login/" + + background_tasks.add_task( + email_sender.send_password_reset_complete_email, + str(data.email), + login_link + ) + return MessageResponseSchema(message="Password reset successfully.") diff --git a/src/routes/profiles.py b/src/routes/profiles.py index 0b7c3420..b900fdb7 100644 --- a/src/routes/profiles.py +++ b/src/routes/profiles.py @@ -1,5 +1,137 @@ -from fastapi import APIRouter +from typing import cast + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import HttpUrl +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import get_s3_storage_client, get_jwt_auth_manager +from database import get_db +from database.models.accounts import UserModel, UserProfileModel, GenderEnum, UserGroupModel, UserGroupEnum +from exceptions import BaseSecurityError, S3FileUploadError +from schemas.profiles import ProfileCreateSchema, ProfileResponseSchema +from security.interfaces import JWTAuthManagerInterface +from security.http import get_token +from storages import S3StorageInterface + router = APIRouter() -# Write your code here + +@router.post( + "/users/{user_id}/profile/", + response_model=ProfileResponseSchema, + summary="Create user profile", + status_code=status.HTTP_201_CREATED +) +async def create_profile( + user_id: int, + token: str = Depends(get_token), + jwt_manager: JWTAuthManagerInterface = Depends(get_jwt_auth_manager), + db: AsyncSession = Depends(get_db), + s3_client: S3StorageInterface = Depends(get_s3_storage_client), + profile_data: ProfileCreateSchema = Depends(ProfileCreateSchema.from_form) +) -> ProfileResponseSchema: + """ + Creates a user profile. + + Steps: + - Validate user authentication token. + - Check if the user already has a profile. + - Upload avatar to S3 storage. + - Store profile details in the database. + + Args: + user_id (int): The ID of the user for whom the profile is being created. + token (str): The authentication token. + jwt_manager (JWTAuthManagerInterface): JWT manager for decoding tokens. + db (AsyncSession): The asynchronous database session. + s3_client (S3StorageInterface): The asynchronous S3 storage client. + profile_data (ProfileCreateSchema): The profile data from the form. + + Returns: + ProfileResponseSchema: The created user profile details. + + Raises: + HTTPException: If authentication fails, if the user is not found or inactive, + or if the profile already exists, or if S3 upload fails. + """ + try: + payload = jwt_manager.decode_access_token(token) + token_user_id = payload.get("user_id") + except BaseSecurityError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e) + ) + + if user_id != token_user_id: + stmt = ( + select(UserGroupModel) + .join(UserModel) + .where(UserModel.id == token_user_id) + ) + result = await db.execute(stmt) + user_group = result.scalars().first() + if not user_group or user_group.name == UserGroupEnum.USER: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to edit this profile." + ) + + stmt = select(UserModel).where(UserModel.id == user_id) + result = await db.execute(stmt) + user = result.scalars().first() + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or not active." + ) + + stmt_profile = select(UserProfileModel).where(UserProfileModel.user_id == user.id) + result_profile = await db.execute(stmt_profile) + existing_profile = result_profile.scalars().first() + if existing_profile: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User already has a profile." + ) + + avatar_bytes = await profile_data.avatar.read() + avatar_key = f"avatars/{user.id}_{profile_data.avatar.filename}" + + try: + await s3_client.upload_file(file_name=avatar_key, file_data=avatar_bytes) + except S3FileUploadError as e: + print(f"Error uploading avatar to S3: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to upload avatar. Please try again later." + ) + + new_profile = UserProfileModel( + user_id=cast(int, user.id), + first_name=profile_data.first_name, + last_name=profile_data.last_name, + gender=cast(GenderEnum, profile_data.gender), + date_of_birth=profile_data.date_of_birth, + info=profile_data.info, + avatar=avatar_key + ) + + db.add(new_profile) + await db.commit() + await db.refresh(new_profile) + + avatar_url = await s3_client.get_file_url(new_profile.avatar) + + 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=cast(HttpUrl, avatar_url) + ) diff --git a/src/schemas/profiles.py b/src/schemas/profiles.py index adbcbcee..a1e6384e 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): + first_name: str + last_name: str + gender: str + date_of_birth: date + info: str + avatar: UploadFile + + @classmethod + def from_form( + cls, + first_name: str = Form(...), + last_name: str = Form(...), + gender: str = Form(...), + date_of_birth: date = Form(...), + info: str = Form(...), + avatar: UploadFile = File(...) + ) -> "ProfileCreateSchema": + return cls( + first_name=first_name, + last_name=last_name, + gender=gender, + date_of_birth=date_of_birth, + info=info, + avatar=avatar, + ) + + @field_validator("first_name", "last_name") + @classmethod + def validate_name_field(cls, value: str) -> str: + validate_name(value) + return value.strip().lower() + + @field_validator("avatar") + @classmethod + def validate_avatar(cls, avatar: UploadFile) -> UploadFile: + validate_image(avatar) + return avatar + + @field_validator("gender") + @classmethod + def validate_gender_field(cls, gender: str) -> str: + validate_gender(gender) + return gender + + @field_validator("date_of_birth") + @classmethod + def validate_date_of_birth_field(cls, date_of_birth: date) -> date: + validate_birth_date(date_of_birth) + return date_of_birth + + @field_validator("info") + @classmethod + def validate_info(cls, info: str) -> str: + cleaned_info = info.strip() + if not cleaned_info: + raise ValueError("Info field cannot be empty or contain only spaces.") + return cleaned_info + + +class ProfileResponseSchema(BaseModel): + id: int + user_id: int + first_name: str + last_name: str + gender: str + date_of_birth: date + info: str + avatar: HttpUrl