Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
44 changes: 42 additions & 2 deletions 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 All @@ -18,7 +18,7 @@
RefreshTokenModel
)
from exceptions import BaseSecurityError
from notifications import EmailSenderInterface
from notifications import EmailSenderInterface, EmailSender
from schemas import (
UserRegistrationRequestSchema,
UserRegistrationResponseSchema,
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:
"""
Endpoint for user registration.
Expand Down Expand Up @@ -120,6 +122,14 @@ async def register_user(

await db.commit()
await db.refresh(new_user)

activation_token_link = (f"http://127.0.0.1:8000/activate/?token="
f"{activation_token.token}&email={user_data.email}")
background_tasks.add_task(
email_sender.send_activation_email,
str(user_data.email),
activation_token_link
)
except SQLAlchemyError as e:
await db.rollback()
raise HTTPException(
Expand Down Expand Up @@ -163,7 +173,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:
"""
Endpoint to activate a user's account.
Expand Down Expand Up @@ -218,6 +230,14 @@ async def activate_account(
await db.delete(token_record)
await db.commit()

login_link = "http://127.0.0.1:8000/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 +253,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:
"""
Endpoint to request a password reset token.
Expand Down Expand Up @@ -262,7 +284,15 @@ async def request_password_reset_token(
reset_token = PasswordResetTokenModel(user_id=cast(int, user.id))
db.add(reset_token)
await db.commit()
await db.refresh(reset_token)

password_complete_link = f"http://127.0.0.1:8000/reset-password/?token={reset_token.token}&email={data.email}"

background_tasks.add_task(
email_sender.send_password_reset_email,
str(data.email),
password_complete_link
)
return MessageResponseSchema(
message="If you are registered, you will receive an email with instructions."
)
Expand Down Expand Up @@ -313,7 +343,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:
"""
Endpoint for resetting a user's password.
Expand Down Expand Up @@ -376,6 +408,14 @@ async def reset_password(
detail="An error occurred while resetting the password."
)

login_link = "http://127.0.0.1:8000/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
113 changes: 112 additions & 1 deletion src/routes/profiles.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,116 @@
from fastapi import APIRouter
from datetime import datetime, timezone, date

from fastapi import Depends, HTTPException, status, APIRouter, Form, UploadFile, File
from sqlalchemy import cast, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db, UserModel, UserProfileModel, UserGroupModel, UserGroupEnum
from config import get_s3_storage_client, get_jwt_auth_manager
from exceptions import BaseSecurityError
from schemas.profiles import ProfileResponseSchema, ProfileCreateRequestSchema
from storages.interfaces import S3StorageInterface
from security.interfaces import JWTAuthManagerInterface
from security.http import get_token

import schemas

router = APIRouter()

# Write your code here


@router.post("/users/{user_id}/profile/", status_code=status.HTTP_201_CREATED)
async def user_profile_creation(
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(...),
token: str = Depends(get_token),
db: AsyncSession = Depends(get_db),
jwt_manager: JWTAuthManagerInterface = Depends(get_jwt_auth_manager),
s3_client: S3StorageInterface = Depends(get_s3_storage_client)
) -> ProfileResponseSchema:

profile_validation = ProfileCreateRequestSchema(
first_name=first_name,
last_name=last_name,
gender=gender,
date_of_birth=date_of_birth,
info=info,
avatar=avatar
)

try:

payload = jwt_manager.decode_access_token(token)
current_user_id = payload.get("user_id")
except BaseSecurityError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired."
)

request = await db.execute(
select(UserModel).filter_by(id=current_user_id)
)
db_current_user = request.scalar_one_or_none()

if not db_current_user:
raise HTTPException(status_code=401, detail="User not found.")

Choose a reason for hiding this comment

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

This check for the authenticated user is a good safeguard. However, it has two points to address:

  1. The error detail "User not found." is not listed in the task's "Full List of Possible Errors". The API contract should be consistent.
  2. The check only verifies if the user exists but doesn't check if they are active. An inactive user might still be able to proceed and create a profile for themselves.

Please consider adding a check for db_current_user.is_active and using an error detail that aligns with the project's API specification.


stmt = select(
UserGroupModel.id
).where(UserGroupModel.name == UserGroupEnum.ADMIN)
db_group_admin_id = await db.scalar(stmt)

if current_user_id != user_id and db_current_user.group_id != db_group_admin_id:
raise HTTPException(
status_code=403,
detail="You don't have permission to edit this profile."
)

request = await db.execute(select(UserModel).filter_by(id=user_id))

db_user = request.scalar_one_or_none()
if not db_user or not db_user.is_active:
raise HTTPException(
status_code=401,
detail="User not found or not active."
)

request = await db.execute(
select(UserProfileModel).where(UserProfileModel.user_id == db_user.id)
)

db_user_profile = request.scalar_one_or_none()

if db_user_profile:
raise HTTPException(status_code=400, detail="User already has a profile.")

try:
avatar_url = await s3_client.upload(avatar)
except Exception:
raise HTTPException(
status_code=500,
detail="Failed to upload avatar. Please try again later."
)

# БЛОК 2: Пишем в БД (если Блок 1 прошел успешно)
db_user_profile = UserProfileModel(
user_id=user_id,
first_name=profile_validation.first_name,
last_name=profile_validation.last_name,
gender=profile_validation.gender,
date_of_birth=profile_validation.date_of_birth,
info=profile_validation.info,
avatar=avatar_url

)
db.add(db_user_profile)
await db.commit()
await db.refresh(db_user_profile)

return ProfileResponseSchema.model_validate(db_user_profile)
52 changes: 51 additions & 1 deletion src/schemas/profiles.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import datetime
from datetime import date
from typing import Any, Self

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,
Expand All @@ -10,4 +12,52 @@
validate_birth_date
)


# Write your code here
class UserProfileBase(BaseModel):
first_name: str
last_name: str
gender: str
date_of_birth: date
info: str

@field_validator("first_name", "last_name")
@classmethod
def validate_fullname(cls, value):
return validate_name(value)

@field_validator("gender")
@classmethod
def validate_gender(cls, value):
return validate_gender(value)

@field_validator("date_of_birth")
@classmethod
def validate_bdate(cls, value):
return validate_birth_date(value)

@field_validator("info")
@classmethod
def validate_info(cls, value):
if not value.strip():
raise ValueError(
"Info cannot be empty or consist only of spaces."
)
return value


class ProfileCreateRequestSchema(UserProfileBase):
avatar: UploadFile

@field_validator("avatar")
@classmethod
def validate_avatar(cls, value):
return validate_image(value)

Comment on lines +18 to +57

Choose a reason for hiding this comment

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

The structure of this base class creates a conflict for the avatar field, which needs to be an UploadFile for requests but a str (URL) for responses.

  1. The avatar field is typed as str (line 23). This is incorrect for ProfileCreateRequestSchema, which must handle a file upload as per the requirements.
  2. The @field_validator("avatar") (line 47) calls validate_image, which expects an UploadFile. Because the field is typed as str, the validator will receive the wrong data type, causing an error.

To resolve this, consider defining avatar with the appropriate type within each specific schema (ProfileCreateRequestSchema and ProfileResponseSchema) rather than in this shared base class.


class ProfileResponseSchema(UserProfileBase):
id: int
user_id: int
avatar: str

model_config = ConfigDict(from_attributes=True)
Loading