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: 35 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 @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.")


Expand All @@ -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.
Expand Down Expand Up @@ -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."
)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.")


Expand Down
85 changes: 83 additions & 2 deletions src/routes/profiles.py
Original file line number Diff line number Diff line change
@@ -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("/",

Choose a reason for hiding this comment

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

The endpoint path is defined as / here, but the task requirements specify it should be /users/{user_id}/profile/. This change is crucial as it affects how you'll handle authorization (e.g., allowing an admin to create a profile for another user). The function signature will also need to be updated to accept user_id from the path.

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",
)
Comment on lines +42 to +46

Choose a reason for hiding this comment

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

This exception handling is too broad. It catches any error during token decoding and returns a generic message. The requirements specify different error details for different token validation failures, such as "Token has expired.". Consider catching a more specific exception, like BaseSecurityError from the exceptions.security module, to provide more accurate feedback.


validate_image(avatar)

Choose a reason for hiding this comment

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

After extracting the user_id from the token, there's a missing validation step. The requirements state that you must check if the user exists in the database and is active. If not, a 401 Unauthorized error with the detail "User not found or not active." should be returned.

Choose a reason for hiding this comment

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

The authorization logic is not fully implemented. According to the requirements, a regular user should only be able to create their own profile. If a regular user tries to create a profile for another user (i.e., user_id from the token does not match user_id from the path), a 403 Forbidden error should be returned. This check is currently missing.


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.",

Choose a reason for hiding this comment

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

The error message here differs from the one specified in the requirements. The task asks for the detail to be "User already has a profile.". It's a good practice to stick to the defined API contract.

)

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)}"

Choose a reason for hiding this comment

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

The error message for a failed avatar upload doesn't match the requirements. It should be exactly "Failed to upload avatar. Please try again later.". Including the raw exception (e) in the response can leak internal implementation details.

)

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:

Choose a reason for hiding this comment

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

This except block is a bit too broad as it catches any possible exception. For database operations, it's better to be more specific and catch SQLAlchemyError instead. This helps ensure you're only handling expected database errors and not accidentally masking other issues. You will need to import it from sqlalchemy.exc.

await db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An error occurred while saving the profile to the database."
)
52 changes: 48 additions & 4 deletions src/schemas/profiles.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +13 to +18

Choose a reason for hiding this comment

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

The task requirements for this file state that the schema should include validation for the avatar using the validate_image function. The ProfileCreateSchema is currently missing the avatar field and its associated validator. Please ensure all required validations are implemented within the schema as described.


@field_validator("first_name", "last_name")
@classmethod
def chek_name(cls, v: str):

Choose a reason for hiding this comment

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

There's a small typo in the method name. It should be check_name to be consistent with the other validators.

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)
Loading