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
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/accounts/login/"

Choose a reason for hiding this comment

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

This hardcoded URL is inconsistent with the others in this file (e.g., in activate_account on line 233), as it is missing the port number :8000. This could prevent the link from working correctly in the development environment. Please ensure all generated URLs are valid and consistent.


background_tasks.add_task(
email_sender.send_password_reset_complete_email,
str(data.email),
login_link
)

return MessageResponseSchema(message="Password reset successfully.")


Expand Down
97 changes: 96 additions & 1 deletion src/routes/profiles.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,100 @@
from fastapi import APIRouter
from datetime import datetime, timezone

from fastapi import Depends, HTTPException, status, APIRouter
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 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,
profile_data: schemas.ProfileCreateRequestSchema,

Choose a reason for hiding this comment

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

This function signature expects the profile_data to be a JSON object in the request body. However, since you are uploading an avatar file, the request content type will be multipart/form-data, not application/json. FastAPI cannot automatically parse a multipart request into a single Pydantic model like this.

You should modify the signature to accept individual form fields and the file separately. For example:

from fastapi import Form, File, UploadFile
from datetime import date

async def user_profile_creation(
    ...,
    first_name: str = Form(),
    last_name: str = Form(),
    ...,
    avatar: UploadFile = File(),
    ...
):
    # Then you can manually create a dictionary or Pydantic model 
    # from these fields to perform validation.

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)
) -> schemas.ProfileResponseSchema:
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(profile_data.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_data.first_name,
last_name=profile_data.last_name,
avatar=avatar_url,
gender=profile_data.gender,
date_of_birth=profile_data.date_of_birth,
info=profile_data.info

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

return schemas.ProfileResponseSchema.model_validate(db_user_profile)
50 changes: 49 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,50 @@
validate_birth_date
)


# Write your code here
class UserProfileBase(BaseModel):
first_name: str
last_name: str
gender: str
date_of_birth: date
info: str
avatar: 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

@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 ProfileCreateRequestSchema(UserProfileBase):
pass


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

model_config = ConfigDict(from_attributes=True)
Loading