diff --git a/src/routes/accounts.py b/src/routes/accounts.py index 82729aac..0f03b49b 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 @@ -68,6 +68,8 @@ async def register_user( user_data: UserRegistrationRequestSchema, db: AsyncSession = Depends(get_db), + background_tasks: BackgroundTasks = BackgroundTasks(), + email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator), ) -> UserRegistrationResponseSchema: """ Endpoint for user registration. @@ -120,6 +122,13 @@ async def register_user( await db.commit() await db.refresh(new_user) + + activation_link = f"http://127.0.0.1:8000/accounts/activate/?email={new_user.email}&token={activation_token.token}" + background_tasks.add_task( + email_sender.send_activation_email, + new_user.email, + activation_link + ) except SQLAlchemyError as e: await db.rollback() raise HTTPException( @@ -164,6 +173,8 @@ async def register_user( async def activate_account( activation_data: UserActivationRequestSchema, db: AsyncSession = Depends(get_db), + background_tasks: BackgroundTasks = BackgroundTasks(), + email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator), ) -> MessageResponseSchema: """ Endpoint to activate a user's account. @@ -218,6 +229,11 @@ async def activate_account( await db.delete(token_record) await db.commit() + background_tasks.add_task( + email_sender.send_account_activated_email, + user.email, + ) + return MessageResponseSchema(message="User account activated successfully.") @@ -234,6 +250,8 @@ async def activate_account( async def request_password_reset_token( data: PasswordResetRequestSchema, db: AsyncSession = Depends(get_db), + background_tasks: BackgroundTasks = BackgroundTasks(), + email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator), ) -> MessageResponseSchema: """ Endpoint to request a password reset token. @@ -263,6 +281,13 @@ async def request_password_reset_token( db.add(reset_token) await db.commit() + reset_link = f"http://127.0.0.1:8000/accounts/reset-password/?email={user.email}&token={reset_token.token}" + background_tasks.add_task( + email_sender.send_password_reset_email, + user.email, + reset_link, + ) + return MessageResponseSchema( message="If you are registered, you will receive an email with instructions." ) @@ -314,6 +339,8 @@ async def request_password_reset_token( async def reset_password( data: PasswordResetCompleteRequestSchema, db: AsyncSession = Depends(get_db), + background_tasks: BackgroundTasks = BackgroundTasks(), + email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator), ) -> MessageResponseSchema: """ Endpoint for resetting a user's password. @@ -376,6 +403,13 @@ async def reset_password( detail="An error occurred while resetting the password." ) + account_link = "http://127.0.0.1:8000/accounts/login/" + background_tasks.add_task( + email_sender.send_password_reset_complete_email, + user.email, + account_link, + ) + return MessageResponseSchema(message="Password reset successfully.") diff --git a/src/routes/profiles.py b/src/routes/profiles.py index 0b7c3420..508bf9b5 100644 --- a/src/routes/profiles.py +++ b/src/routes/profiles.py @@ -1,5 +1,121 @@ -from fastapi import APIRouter +from datetime import date +from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile, Form +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from database import get_db, UserProfileModel, UserModel, UserGroupEnum +from database.models.accounts import GenderEnum +from schemas.profiles import ProfileResponseSchema, ProfileCreateSchema +from security.http import get_token +from config.dependencies import get_jwt_auth_manager, get_s3_storage_client +from exceptions import BaseSecurityError router = APIRouter() -# Write your code here +@router.post("/users/{user_id}/profile/", + response_model=ProfileResponseSchema, + status_code=status.HTTP_201_CREATED, + summary="Create User profile", +) +async def create_profile( + user_id: int, + first_name: str = Form(...), + last_name: str = Form(...), + gender: GenderEnum = Form(...), + date_of_birth: date = Form(...), + info: str = Form(...), + avatar: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + token: str = Depends(get_token), + jwt_manager = Depends(get_jwt_auth_manager), + s3_client = Depends(get_s3_storage_client) +): + try: + payload = jwt_manager.decode_access_token(token) + token_user_id = payload.get("user_id") + + except BaseSecurityError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired.", + ) + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + ) + + + user_stmt = select(UserModel).where(UserModel.id == token_user_id) + user_result = await db.execute(user_stmt) + current_user = user_result.scalars().first() + + target_user_stmt = select(UserModel).where(UserModel.id == user_id) + target_user_result = await db.execute(target_user_stmt) + target_user = target_user_result.scalars().first() + + if not current_user or not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or not active.", + ) + + 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.", + ) + + is_admin = current_user.group.name == UserGroupEnum.ADMIN + if token_user_id != user_id and not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to edit this profile." + ) + + ProfileCreateSchema( + first_name=first_name, + last_name=last_name, + gender=gender.value, + date_of_birth=date_of_birth, + info=info, + avatar=avatar + ) + stmt = select(UserProfileModel).where(UserProfileModel.user_id == user_id) + result = await db.execute(stmt) + if result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User already has a profile.", + ) + + object_key = f"avatars/{user_id}_{avatar.filename}" + + try: + avatar_url = await s3_client.upload_file(avatar, object_key) + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to upload avatar. Please try again later." + ) + + try: + new_profile = UserProfileModel( + user_id=user_id, + first_name=first_name, + last_name=last_name, + gender=gender, + date_of_birth=date_of_birth, + info=info, + avatar=avatar_url, + ) + db.add(new_profile) + await db.commit() + await db.refresh(new_profile) + return new_profile + except Exception: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while saving the profile to the database." + ) diff --git a/src/schemas/profiles.py b/src/schemas/profiles.py index adbcbcee..671725b5 100644 --- a/src/schemas/profiles.py +++ b/src/schemas/profiles.py @@ -1,13 +1,64 @@ from datetime import date +from typing import Optional, Any -from fastapi import UploadFile, Form, File, HTTPException -from pydantic import BaseModel, field_validator, HttpUrl +from pydantic import BaseModel, field_validator, ConfigDict from validation import ( validate_name, - validate_image, validate_gender, - validate_birth_date + validate_birth_date, + validate_image ) -# Write your code here +class ProfileCreateSchema(BaseModel): + first_name: str + last_name: str + gender: str + date_of_birth: date + info: str + avatar: Any + + @field_validator("first_name", "last_name") + @classmethod + def check_name(cls, v: str): + validate_name(v) + return v + + @field_validator("gender") + @classmethod + def check_gender(cls, v: str): + validate_gender(v) + return v + + @field_validator("date_of_birth") + @classmethod + def validate_birth_date(cls, v: date): + validate_birth_date(v) + return v + + + @field_validator("info") + @classmethod + def check_info(cls, v: str): + if not v or v.isspace(): + raise ValueError("Info cannot be empty or consist only of spaces.") + return v + + @field_validator("avatar") + @classmethod + def validate_avatar_field(cls, v: Any): + if v: + validate_image(v) + return v + +class ProfileResponseSchema(BaseModel): + id: int + user_id: int + first_name: str + last_name: str + gender: str + date_of_birth: date + info: str + avatar: Optional[str] + + model_config = ConfigDict(from_attributes=True)