Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
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
43 changes: 42 additions & 1 deletion src/routes/accounts.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Comment on lines 68 to 73

Choose a reason for hiding this comment

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

According to the task requirements, email notifications must be sent asynchronously. Please add background_tasks: BackgroundTasks to the function parameters and use background_tasks.add_task() later to send the email, instead of calling it directly with await.

"""
Endpoint for user registration.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Comment on lines 175 to 180

Choose a reason for hiding this comment

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

Similar to the registration endpoint, this function should use BackgroundTasks to send the completion email asynchronously. Please inject BackgroundTasks and use background_tasks.add_task() for sending the email.

"""
Endpoint to activate a user's account.
Expand Down Expand Up @@ -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.")


Expand All @@ -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:
Comment on lines 255 to 260

Choose a reason for hiding this comment

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

This function should use BackgroundTasks for sending the password reset email asynchronously. Please update the function signature to include background_tasks: BackgroundTasks and use background_tasks.add_task() to dispatch the email.

"""
Endpoint to request a password reset token.
Expand Down Expand Up @@ -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."
)
Expand Down Expand Up @@ -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:
Comment on lines 345 to 350

Choose a reason for hiding this comment

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

The password reset completion email should be sent in the background. Please inject BackgroundTasks into this function and use background_tasks.add_task() to send the notification asynchronously.

"""
Endpoint for resetting a user's password.
Expand Down Expand Up @@ -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.")


Expand Down
136 changes: 134 additions & 2 deletions src/routes/profiles.py
Original file line number Diff line number Diff line change
@@ -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)

2 changes: 1 addition & 1 deletion src/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@
UserLoginRequestSchema,
TokenRefreshRequestSchema,
TokenRefreshResponseSchema
)
)

Choose a reason for hiding this comment

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

Good job exposing the account schemas. However, you've missed exporting the new profile schemas (ProfileCreateSchema, ProfileResponseSchema) from schemas.profiles. To keep the project structure consistent, they should be imported here as well.

Choose a reason for hiding this comment

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

The new schemas defined in schemas/profiles.py should be imported and exposed here, just like the schemas from accounts and movies.

78 changes: 76 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,78 @@
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
Loading