Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/routes/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
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,
Expand Down
249 changes: 247 additions & 2 deletions src/routes/profiles.py
Original file line number Diff line number Diff line change
@@ -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
)
3 changes: 3 additions & 0 deletions src/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@
TokenRefreshRequestSchema,
TokenRefreshResponseSchema
)
from schemas.profiles import (
ProfileResponseSchema,
)
Comment on lines +20 to +23

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ProfileCreateSchema is defined in schemas/profiles.py but is not exported here. It's good practice to export all public schemas from the package's __init__.py file for consistency.

73 changes: 71 additions & 2 deletions src/schemas/profiles.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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
}
Loading
Loading