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
36 changes: 36 additions & 0 deletions src/routes/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
async def register_user(
user_data: UserRegistrationRequestSchema,
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 All @@ -79,6 +80,7 @@ async def register_user(
Args:
user_data (UserRegistrationRequestSchema): The registration details including email and password.
db (AsyncSession): The asynchronous database session.
email_sender (EmailSenderInterface): The asynchronous email sender.

Returns:
UserRegistrationResponseSchema: The newly created user's details.
Expand Down Expand Up @@ -127,6 +129,13 @@ async def register_user(
detail="An error occurred during user creation."
) from e
else:
activation_link = "http://127.0.0.1/accounts/activate/"

Choose a reason for hiding this comment

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

The activation_link must include the generated token to be functional for the user. You'll need to retrieve the token from the activation_token object created earlier and append it to this URL, perhaps as a query parameter. You might need to refresh the activation_token object after the database commit to access its value.


await email_sender.send_activation_email(
new_user.email,
activation_link
)

return UserRegistrationResponseSchema.model_validate(new_user)


Expand Down Expand Up @@ -164,6 +173,7 @@ async def register_user(
async def activate_account(
activation_data: UserActivationRequestSchema,
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 All @@ -176,6 +186,7 @@ async def activate_account(
Args:
activation_data (UserActivationRequestSchema): Contains the user's email and activation token.
db (AsyncSession): The asynchronous database session.
email_sender (EmailSenderInterface): The asynchronous email sender.

Returns:
MessageResponseSchema: A response message confirming successful activation.
Expand Down Expand Up @@ -218,6 +229,13 @@ async def activate_account(
await db.delete(token_record)
await db.commit()

login_link = "http://127.0.0.1/accounts/login/"

await email_sender.send_activation_complete_email(
str(activation_data.email),
login_link
)

return MessageResponseSchema(message="User account activated successfully.")


Expand All @@ -234,6 +252,7 @@ async def activate_account(
async def request_password_reset_token(
data: PasswordResetRequestSchema,
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 All @@ -244,6 +263,7 @@ async def request_password_reset_token(
Args:
data (PasswordResetRequestSchema): The request data containing the user's email.
db (AsyncSession): The asynchronous database session.
email_sender (EmailSenderInterface): The asynchronous email sender.

Returns:
MessageResponseSchema: A success message indicating that instructions will be sent.
Expand All @@ -263,6 +283,13 @@ async def request_password_reset_token(
db.add(reset_token)
await db.commit()

password_reset_complete_link = "http://127.0.0.1/accounts/password-reset-complete/"

Choose a reason for hiding this comment

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

This password reset link is not functional because it's missing the unique reset token. To make it work, you need to include the token from the reset_token object in the URL.


await email_sender.send_password_reset_email(
str(data.email),
password_reset_complete_link
)

return MessageResponseSchema(
message="If you are registered, you will receive an email with instructions."
)
Expand Down Expand Up @@ -314,6 +341,7 @@ async def request_password_reset_token(
async def reset_password(
data: PasswordResetCompleteRequestSchema,
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 All @@ -325,6 +353,7 @@ async def reset_password(
data (PasswordResetCompleteRequestSchema): The request data containing the user's email,
token, and new password.
db (AsyncSession): The asynchronous database session.
email_sender (EmailSenderInterface): The asynchronous email sender.

Returns:
MessageResponseSchema: A response message indicating successful password reset.
Expand Down Expand Up @@ -376,6 +405,13 @@ async def reset_password(
detail="An error occurred while resetting the password."
)

login_link = "http://127.0.0.1/accounts/login/"

await 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.

123 changes: 122 additions & 1 deletion src/schemas/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,125 @@
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, name: str) -> str:
try:
validate_name(name)
return name.lower()
except ValueError as e:
raise HTTPException(
status_code=422,
detail=[{
"type": "value_error",
"loc": ["first_name" if "first_name" in name else "last_name"],

Choose a reason for hiding this comment

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

The logic for determining the error location (loc) is incorrect. The name variable holds the field's value (e.g., "John"), not its name. This condition checks if the string "first_name" is a substring of the user's provided name, which is not the intended behavior and will lead to incorrect error reporting. Consider splitting this into two separate validator methods, one for first_name and one for last_name, to correctly specify the loc.

"msg": str(e),
"input": name
}]
)

@field_validator("avatar")
@classmethod
def validate_avatar(cls, avatar: UploadFile) -> UploadFile:
try:
validate_image(avatar)
return avatar
except ValueError as e:
raise HTTPException(
status_code=422,
detail=[{
"type": "value_error",
"loc": ["avatar"],
"msg": str(e),
"input": avatar.filename
}]
)

@field_validator("gender")
@classmethod
def validate_gender(cls, gender: str) -> str:
try:
validate_gender(gender)
return gender
except ValueError as e:
raise HTTPException(
status_code=422,
detail=[{
"type": "value_error",
"loc": ["gender"],
"msg": str(e),
"input": gender
}]
)

@field_validator("date_of_birth")
@classmethod
def validate_date_of_birth(cls, date_of_birth: date) -> date:
try:
validate_birth_date(date_of_birth)
return date_of_birth
except ValueError as e:
raise HTTPException(
status_code=422,
detail=[{
"type": "value_error",
"loc": ["date_of_birth"],
"msg": str(e),
"input": str(date_of_birth)
}]
)

@field_validator("info")
@classmethod
def validate_info(cls, info: str) -> str:
cleaned_info = info.strip()
if not cleaned_info:
raise HTTPException(
status_code=422,
detail=[{
"type": "value_error",
"loc": ["info"],
"msg": "Info field cannot be empty or contain only spaces.",
"input": info
}]
)
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