diff --git a/src/routes/accounts.py b/src/routes/accounts.py index 82729aac..93d20223 100644 --- a/src/routes/accounts.py +++ b/src/routes/accounts.py @@ -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 @@ -18,7 +18,7 @@ RefreshTokenModel ) from exceptions import BaseSecurityError -from notifications import EmailSenderInterface +from notifications import EmailSenderInterface, EmailSender from schemas import ( UserRegistrationRequestSchema, UserRegistrationResponseSchema, @@ -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. @@ -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( @@ -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. @@ -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.") @@ -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. @@ -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." ) @@ -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. @@ -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.") diff --git a/src/routes/profiles.py b/src/routes/profiles.py index 0b7c3420..6542cc95 100644 --- a/src/routes/profiles.py +++ b/src/routes/profiles.py @@ -1,5 +1,119 @@ -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 or not db_current_user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or token." + ) + + 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) diff --git a/src/schemas/profiles.py b/src/schemas/profiles.py index adbcbcee..5173a5d9 100644 --- a/src/schemas/profiles.py +++ b/src/schemas/profiles.py @@ -1,7 +1,10 @@ +import datetime from datetime import date +from typing import Any +from typing_extensions import 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, @@ -10,4 +13,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) + + +class ProfileResponseSchema(UserProfileBase): + id: int + user_id: int + avatar: str + + model_config = ConfigDict(from_attributes=True)