Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
db:
image: 'postgres:latest'
image: 'postgres:15'
container_name: postgres_theater
env_file:
- .env
Expand Down
3,478 changes: 1,929 additions & 1,549 deletions poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ asyncpg = "^0.30.0"
aiosqlite = "^0.21.0"
aioboto3 = "^13.4.0"
pytest-asyncio = "^0.25.3"
greenlet = "^3.3.2"


[build-system]
Expand Down
45 changes: 39 additions & 6 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,6 +67,8 @@
)
async def register_user(
user_data: UserRegistrationRequestSchema,
background_tasks: BackgroundTasks,
email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator),
db: AsyncSession = Depends(get_db),
) -> UserRegistrationResponseSchema:
"""
Expand Down Expand Up @@ -117,9 +119,16 @@ async def register_user(

activation_token = ActivationTokenModel(user_id=new_user.id)
db.add(activation_token)

await db.commit()
await db.refresh(new_user)
activation_link = f"http://127.0.0.1/accounts/activate/?token={activation_token.token}"

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 +172,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 +229,12 @@ 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(user.email),
login_link
)
return MessageResponseSchema(message="User account activated successfully.")


Expand All @@ -233,7 +250,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 @@ -263,6 +282,13 @@ 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}"
background_tasks.add_task(
email_sender.send_password_reset_email,
str(user.email),
reset_link
)

return MessageResponseSchema(
message="If you are registered, you will receive an email with instructions."
)
Expand All @@ -277,8 +303,8 @@ async def request_password_reset_token(
responses={
400: {
"description": (
"Bad Request - The provided email or token is invalid, "
"the token has expired, or the user account is not active."
"Bad Request - The provided email or token is invalid, "
"the token has expired, or the user account is not active."
),
"content": {
"application/json": {
Expand Down Expand Up @@ -313,7 +339,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 @@ -375,7 +403,12 @@ async def reset_password(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
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(user.email),
login_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 typing import Annotated

from fastapi import APIRouter, status, Depends, UploadFile, File, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload

from config import get_s3_storage_client, get_jwt_auth_manager
from database import get_db, UserModel, UserProfileModel

from schemas.profiles import ProfileResponseSchema, ProfileRequestSchema
from security.http import get_token
from security.interfaces import JWTAuthManagerInterface
from storages import S3StorageInterface

router = APIRouter()

# Write your code here

@router.post(
"/users/{user_id}/profile/",
response_model=ProfileResponseSchema,
status_code=status.HTTP_201_CREATED
)
async def create_profile(
user_id: int,
data: ProfileRequestSchema = Depends(ProfileRequestSchema.as_form),
db: AsyncSession = Depends(get_db),
token: str = Depends(get_token),
jwt_manager: JWTAuthManagerInterface = Depends(get_jwt_auth_manager),
storage: S3StorageInterface = Depends(get_s3_storage_client),
):
try:
payload = jwt_manager.decode_access_token(token)
token_user_id = payload.get("user_id")
except Exception as e:

Choose a reason for hiding this comment

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

Catching a generic Exception here is a bit too broad. It could catch unexpected errors that aren't related to token validation and report them as an expired token. It's better practice to catch a more specific exception. For instance, in routes/accounts.py, BaseSecurityError is used for handling token decoding errors.

raise HTTPException(status_code=401, detail="Token has expired.")

if token_user_id != user_id:
res_group = await db.execute(
select(UserModel).options(joinedload(UserModel.group)).where(UserModel.id == token_user_id)
)
current_user = res_group.scalar_one_or_none()
if not current_user or current_user.group.name != "admin":
raise HTTPException(status_code=403, detail="You don't have permission to edit this profile.")

res_target = await db.execute(select(UserModel).where(UserModel.id == user_id))
target_user = res_target.scalar_one_or_none()
if not target_user or not target_user.is_active:
raise HTTPException(status_code=401, detail="User not found or not active.")

res_profile = await db.execute(select(UserProfileModel).where(UserProfileModel.user_id == user_id))
if res_profile.scalars().first():
raise HTTPException(status_code=400, detail="User already has a profile.")

try:
contents = await data.avatar.read()
avatar_key = f"avatars/{user_id}_{data.avatar.filename}"
await storage.upload_file(avatar_key, contents)
except Exception:

Choose a reason for hiding this comment

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

Similar to the token decoding, catching a generic Exception for the file upload can hide the true cause of a problem. The storage client might raise more specific exceptions for different issues (e.g., connection errors, permission problems). Consider catching a more specific exception related to storage operations to make debugging easier.

raise HTTPException(status_code=500, detail="Failed to upload avatar. Please try again later.")

profile_data = data.model_dump()
profile_data.pop('avatar')

new_profile = UserProfileModel(
user_id=user_id,
**profile_data,
avatar=avatar_key
)

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

avatar_url = await storage.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=avatar_url
)
120 changes: 118 additions & 2 deletions src/schemas/profiles.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,129 @@
from datetime import date
from typing import Annotated, Optional

from fastapi import UploadFile, Form, File, HTTPException
from pydantic import BaseModel, field_validator, HttpUrl
from pydantic import BaseModel, field_validator, HttpUrl, StringConstraints

from database.models.accounts import GenderEnum
from validation import (
validate_name,
validate_image,
validate_gender,
validate_birth_date
)

# Write your code here

class ProfileRequestSchema(BaseModel):
first_name: str
last_name: str
gender: GenderEnum
date_of_birth: date
info: str
avatar: UploadFile

@classmethod
def as_form(
cls,
first_name: str = Form(...),
last_name: str = Form(...),
gender: str = Form(...),
date_of_birth: date = Form(...),
info: str = Form(...),
avatar: UploadFile = File(...)
) -> "ProfileRequestSchema":
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 check_names(cls, v: str, info) -> str:
try:
validate_name(v)
return v.lower()
except ValueError as e:
raise HTTPException(
status_code=422,
detail=[{"type": "value_error", "loc": ["body", info.field_name], "msg": str(e), "input": v}]
)

@field_validator("gender", mode='before')
@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 check_birth_date(cls, v: date) -> date:
try:
validate_birth_date(v)
return v
except ValueError as e:
raise HTTPException(
status_code=422,
detail=[{"type": "value_error", "loc": ["body", "date_of_birth"], "msg": str(e), "input": str(v)}]
)

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

@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: GenderEnum
date_of_birth: date
info: Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1)
]
avatar: Optional[HttpUrl]
Loading