From 84ac83fadb8cee8eae91550eed70f31416b575b9 Mon Sep 17 00:00:00 2001 From: Vitalii Ivanchenko Date: Thu, 26 Feb 2026 11:54:03 +0200 Subject: [PATCH 1/3] Solution --- src/routes/accounts.py | 36 ++++++++++++++++- src/routes/profiles.py | 85 ++++++++++++++++++++++++++++++++++++++++- src/schemas/profiles.py | 52 +++++++++++++++++++++++-- 3 files changed, 166 insertions(+), 7 deletions(-) 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..d849cc62 100644 --- a/src/routes/profiles.py +++ b/src/routes/profiles.py @@ -1,5 +1,86 @@ -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 +from database.models.accounts import GenderEnum +from schemas.profiles import ProfileResponseSchema, ProfileCreateSchema +from validation import validate_image +from security.http import get_token +from config.dependencies import get_jwt_auth_manager, get_s3_storage_client router = APIRouter() -# Write your code here +@router.post("/", + response_model=ProfileResponseSchema, + status_code=status.HTTP_201_CREATED, + summary="Create User profile", + description="Створює профіль користувача, завантажує аватар у MinIO та зберігає метадані в БД." +) +async def create_profile( + 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) + user_id = payload.get("user_id") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User ID not found in token", + ) + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + ) + + validate_image(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="Profile already exists for this user.", + ) + + object_key = f"avatars/{user_id}_{avatar.filename}" + + try: + avatar_url = await s3_client.upload_file(avatar, object_key) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to upload image to storage: {str(e)}" + ) + + 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..af6eaf5a 100644 --- a/src/schemas/profiles.py +++ b/src/schemas/profiles.py @@ -1,13 +1,57 @@ from datetime import date +from typing import Optional -from fastapi import UploadFile, Form, File, HTTPException -from pydantic import BaseModel, field_validator, HttpUrl +from pydantic import BaseModel, field_validator, HttpUrl, ConfigDict from validation import ( validate_name, - validate_image, validate_gender, validate_birth_date ) -# Write your code here +class ProfileCreateSchema(BaseModel): + first_name: str + last_name: str + gender: str + date_of_birth: date + info: str + + @field_validator("first_name", "last_name") + @classmethod + def chek_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 + + +class ProfileResponseSchema(BaseModel): + id: int + user_id: int + first_name: str + last_name: str + gender: str + date_of_birth: date + info: str + avatar: Optional[HttpUrl] = None + + model_config = ConfigDict(from_attributes=True) From a12e35148aa78587bf292f1f965d129faced1df3 Mon Sep 17 00:00:00 2001 From: Vitalii Ivanchenko Date: Thu, 26 Feb 2026 12:39:02 +0200 Subject: [PATCH 2/3] fixed solution --- src/routes/profiles.py | 52 ++++++++++++++++++++++++++++++----------- src/schemas/profiles.py | 19 ++++++++++----- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/routes/profiles.py b/src/routes/profiles.py index d849cc62..f3f8a54b 100644 --- a/src/routes/profiles.py +++ b/src/routes/profiles.py @@ -3,22 +3,22 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -from database import get_db, UserProfileModel +from database import get_db, UserProfileModel, UserModel, UserGroupEnum from database.models.accounts import GenderEnum from schemas.profiles import ProfileResponseSchema, ProfileCreateSchema -from validation import validate_image from security.http import get_token from config.dependencies import get_jwt_auth_manager, get_s3_storage_client +from exceptions import BaseSecurityError router = APIRouter() -@router.post("/", +@router.post("/users/{user_id}/profile/", response_model=ProfileResponseSchema, status_code=status.HTTP_201_CREATED, summary="Create User profile", - description="Створює профіль користувача, завантажує аватар у MinIO та зберігає метадані в БД." ) async def create_profile( + user_id: int, first_name: str = Form(...), last_name: str = Form(...), gender: GenderEnum = Form(...), @@ -32,36 +32,60 @@ async def create_profile( ): try: payload = jwt_manager.decode_access_token(token) - user_id = payload.get("user_id") - if not user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User ID not found in 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", ) - validate_image(avatar) + user_stmt = select(UserModel).where(UserModel.id == token_user_id) + user_result = await db.execute(user_stmt) + current_user = 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.", + ) + + 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="Forbidden", + ) + + 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="Profile already exists for this user.", + 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 as e: + except Exception: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to upload image to storage: {str(e)}" + detail="Failed to upload avatar. Please try again later." ) try: diff --git a/src/schemas/profiles.py b/src/schemas/profiles.py index af6eaf5a..671725b5 100644 --- a/src/schemas/profiles.py +++ b/src/schemas/profiles.py @@ -1,12 +1,13 @@ from datetime import date -from typing import Optional +from typing import Optional, Any -from pydantic import BaseModel, field_validator, HttpUrl, ConfigDict +from pydantic import BaseModel, field_validator, ConfigDict from validation import ( validate_name, validate_gender, - validate_birth_date + validate_birth_date, + validate_image ) class ProfileCreateSchema(BaseModel): @@ -15,10 +16,11 @@ class ProfileCreateSchema(BaseModel): gender: str date_of_birth: date info: str + avatar: Any @field_validator("first_name", "last_name") @classmethod - def chek_name(cls, v: str): + def check_name(cls, v: str): validate_name(v) return v @@ -28,7 +30,6 @@ def check_gender(cls, v: str): validate_gender(v) return v - @field_validator("date_of_birth") @classmethod def validate_birth_date(cls, v: date): @@ -43,6 +44,12 @@ def check_info(cls, v: str): 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 @@ -52,6 +59,6 @@ class ProfileResponseSchema(BaseModel): gender: str date_of_birth: date info: str - avatar: Optional[HttpUrl] = None + avatar: Optional[str] model_config = ConfigDict(from_attributes=True) From 27bd716f69c889a460302d90d222594eafe5a5f1 Mon Sep 17 00:00:00 2001 From: Vitalii Ivanchenko Date: Thu, 26 Feb 2026 12:47:06 +0200 Subject: [PATCH 3/3] finall_fixed solution --- src/routes/profiles.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/routes/profiles.py b/src/routes/profiles.py index f3f8a54b..508bf9b5 100644 --- a/src/routes/profiles.py +++ b/src/routes/profiles.py @@ -49,17 +49,28 @@ async def create_profile( 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="Forbidden", + detail="You don't have permission to edit this profile." ) ProfileCreateSchema(