diff --git a/src/routes/accounts.py b/src/routes/accounts.py index 82729aac..7102ec89 100644 --- a/src/routes/accounts.py +++ b/src/routes/accounts.py @@ -1,13 +1,18 @@ 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 from sqlalchemy.orm import joinedload -from config import get_jwt_auth_manager, get_settings, BaseAppSettings, get_accounts_email_notificator +from config import ( + get_jwt_auth_manager, + get_settings, + BaseAppSettings, + get_accounts_email_notificator, +) from database import ( get_db, UserModel, @@ -15,7 +20,7 @@ UserGroupEnum, ActivationTokenModel, PasswordResetTokenModel, - RefreshTokenModel + RefreshTokenModel, ) from exceptions import BaseSecurityError from notifications import EmailSenderInterface @@ -29,13 +34,21 @@ UserLoginResponseSchema, UserLoginRequestSchema, TokenRefreshRequestSchema, - TokenRefreshResponseSchema + TokenRefreshResponseSchema, ) from security.interfaces import JWTAuthManagerInterface router = APIRouter() +def _base_url(settings: BaseAppSettings) -> str: + """ + Build base URL for links in emails. + If you have settings like APP_BASE_URL / BASE_URL, adjust here. + """ + return cast(str, getattr(settings, "APP_BASE_URL", "http://127.0.0.1:8000")).rstrip("/") + + @router.post( "/register/", response_model=UserRegistrationResponseSchema, @@ -63,25 +76,20 @@ } }, }, - } + }, ) async def register_user( - user_data: UserRegistrationRequestSchema, - db: AsyncSession = Depends(get_db), + user_data: UserRegistrationRequestSchema, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + settings: BaseAppSettings = Depends(get_settings), + email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator), ) -> UserRegistrationResponseSchema: """ Endpoint for user registration. Registers a new user, hashes their password, and assigns them to the default user group. - If a user with the same email already exists, an HTTP 409 error is raised. - In case of any unexpected issues during the creation process, an HTTP 500 error is returned. - - Args: - user_data (UserRegistrationRequestSchema): The registration details including email and password. - db (AsyncSession): The asynchronous database session. - - Returns: - UserRegistrationResponseSchema: The newly created user's details. + Sends activation email in background. Raises: HTTPException: @@ -94,7 +102,7 @@ async def register_user( if existing_user: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=f"A user with this email {user_data.email} already exists." + detail=f"A user with this email {user_data.email} already exists.", ) stmt = select(UserGroupModel).where(UserGroupModel.name == UserGroupEnum.USER) @@ -103,7 +111,7 @@ async def register_user( if not user_group: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Default user group not found." + detail="Default user group not found.", ) try: @@ -120,14 +128,26 @@ async def register_user( await db.commit() await db.refresh(new_user) + await db.refresh(activation_token) except SQLAlchemyError as e: await db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="An error occurred during user creation." + detail="An error occurred during user creation.", ) from e - else: - return UserRegistrationResponseSchema.model_validate(new_user) + + # ✅ Email notification: activation request + activation_link = ( + f"{_base_url(settings)}/api/v1/accounts/activate/?" + f"email={new_user.email}&token={activation_token.token}" + ) + background_tasks.add_task( + email_sender.send_activation_request_email, # ⚠️ звір назву у notifications/emails.py + str(new_user.email), + activation_link, + ) + + return UserRegistrationResponseSchema.model_validate(new_user) @router.post( @@ -139,21 +159,17 @@ async def register_user( responses={ 400: { "description": "Bad Request - The activation token is invalid or expired, " - "or the user account is already active.", + "or the user account is already active.", "content": { "application/json": { "examples": { "invalid_token": { "summary": "Invalid Token", - "value": { - "detail": "Invalid or expired activation token." - } + "value": {"detail": "Invalid or expired activation token."}, }, "already_active": { "summary": "Account Already Active", - "value": { - "detail": "User account is already active." - } + "value": {"detail": "User account is already active."}, }, } } @@ -162,28 +178,15 @@ async def register_user( }, ) async def activate_account( - activation_data: UserActivationRequestSchema, - db: AsyncSession = Depends(get_db), + activation_data: UserActivationRequestSchema, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + settings: BaseAppSettings = Depends(get_settings), + email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator), ) -> MessageResponseSchema: """ Endpoint to activate a user's account. - - This endpoint verifies the activation token for a user by checking that the token record exists - and that it has not expired. If the token is valid and the user's account is not already active, - the user's account is activated and the activation token is deleted. If the token is invalid, expired, - or if the account is already active, an HTTP 400 error is raised. - - Args: - activation_data (UserActivationRequestSchema): Contains the user's email and activation token. - db (AsyncSession): The asynchronous database session. - - Returns: - MessageResponseSchema: A response message confirming successful activation. - - Raises: - HTTPException: - - 400 Bad Request if the activation token is invalid or expired. - - 400 Bad Request if the user account is already active. + Sends activation complete email in background. """ stmt = ( select(ActivationTokenModel) @@ -191,33 +194,43 @@ async def activate_account( .join(UserModel) .where( UserModel.email == activation_data.email, - ActivationTokenModel.token == activation_data.token + ActivationTokenModel.token == activation_data.token, ) ) result = await db.execute(stmt) token_record = result.scalars().first() now_utc = datetime.now(timezone.utc) - if not token_record or cast(datetime, token_record.expires_at).replace(tzinfo=timezone.utc) < now_utc: + if not token_record or cast(datetime, token_record.expires_at).replace( + tzinfo=timezone.utc + ) < now_utc: if token_record: await db.delete(token_record) await db.commit() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid or expired activation token." + detail="Invalid or expired activation token.", ) user = token_record.user if user.is_active: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="User account is already active." + detail="User account is already active.", ) user.is_active = True await db.delete(token_record) await db.commit() + # ✅ Email notification: activation complete + login_link = f"{_base_url(settings)}/accounts/login/" + background_tasks.add_task( + email_sender.send_activation_complete_email, # ⚠️ звір назву + str(user.email), + login_link, + ) + return MessageResponseSchema(message="User account activated successfully.") @@ -226,42 +239,51 @@ async def activate_account( response_model=MessageResponseSchema, summary="Request Password Reset Token", description=( - "Allows a user to request a password reset token. If the user exists and is active, " - "a new token will be generated and any existing tokens will be invalidated." + "Allows a user to request a password reset token. If the user exists and is active, " + "a new token will be generated and any existing tokens will be invalidated." ), status_code=status.HTTP_200_OK, ) async def request_password_reset_token( - data: PasswordResetRequestSchema, - db: AsyncSession = Depends(get_db), + data: PasswordResetRequestSchema, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + settings: BaseAppSettings = Depends(get_settings), + email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator), ) -> MessageResponseSchema: """ Endpoint to request a password reset token. - - If the user exists and is active, invalidates any existing password reset tokens and generates a new one. - Always responds with a success message to avoid leaking user information. - - Args: - data (PasswordResetRequestSchema): The request data containing the user's email. - db (AsyncSession): The asynchronous database session. - - Returns: - MessageResponseSchema: A success message indicating that instructions will be sent. + Sends password reset request email in background (only if user exists and active). """ stmt = select(UserModel).filter_by(email=data.email) result = await db.execute(stmt) user = result.scalars().first() if not user or not user.is_active: + # Don't leak user existence return MessageResponseSchema( message="If you are registered, you will receive an email with instructions." ) - await db.execute(delete(PasswordResetTokenModel).where(PasswordResetTokenModel.user_id == user.id)) + await db.execute( + delete(PasswordResetTokenModel).where(PasswordResetTokenModel.user_id == user.id) + ) reset_token = PasswordResetTokenModel(user_id=cast(int, user.id)) db.add(reset_token) await db.commit() + await db.refresh(reset_token) + + # ✅ Email notification: password reset request + reset_link = ( + f"{_base_url(settings)}/api/v1/accounts/reset-password/complete/?" + f"email={user.email}&token={reset_token.token}" + ) + background_tasks.add_task( + email_sender.send_password_reset_request_email, # ⚠️ звір назву + str(user.email), + reset_link, + ) return MessageResponseSchema( message="If you are registered, you will receive an email with instructions." @@ -285,16 +307,12 @@ async def request_password_reset_token( "examples": { "invalid_email_or_token": { "summary": "Invalid Email or Token", - "value": { - "detail": "Invalid email or token." - } + "value": {"detail": "Invalid email or token."}, }, "expired_token": { "summary": "Expired Token", - "value": { - "detail": "Invalid email or token." - } - } + "value": {"detail": "Invalid email or token."}, + }, } } }, @@ -312,27 +330,15 @@ async def request_password_reset_token( }, ) async def reset_password( - data: PasswordResetCompleteRequestSchema, - db: AsyncSession = Depends(get_db), + data: PasswordResetCompleteRequestSchema, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + settings: BaseAppSettings = Depends(get_settings), + email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator), ) -> MessageResponseSchema: """ Endpoint for resetting a user's password. - - Validates the token and updates the user's password if the token is valid and not expired. - Deletes the token after a successful password reset. - - Args: - data (PasswordResetCompleteRequestSchema): The request data containing the user's email, - token, and new password. - db (AsyncSession): The asynchronous database session. - - Returns: - MessageResponseSchema: A response message indicating successful password reset. - - Raises: - HTTPException: - - 400 Bad Request if the email or token is invalid, or the token has expired. - - 500 Internal Server Error if an error occurs during the password reset process. + Sends password reset complete email in background. """ stmt = select(UserModel).filter_by(email=data.email) result = await db.execute(stmt) @@ -340,7 +346,7 @@ async def reset_password( if not user or not user.is_active: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid email or token." + detail="Invalid email or token.", ) stmt = select(PasswordResetTokenModel).filter_by(user_id=user.id) @@ -353,7 +359,7 @@ async def reset_password( await db.commit() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid email or token." + detail="Invalid email or token.", ) expires_at = cast(datetime, token_record.expires_at).replace(tzinfo=timezone.utc) @@ -362,20 +368,31 @@ async def reset_password( await db.commit() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid email or token." + detail="Invalid email or token.", ) try: + # ⚠️ якщо в тебе є метод set_password / PasswordManager — краще ним. + # Тут залишаю як у твоєму коді, щоб мінімально міняти. user.password = data.password + await db.run_sync(lambda s: s.delete(token_record)) await db.commit() except SQLAlchemyError: await db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="An error occurred while resetting the password." + detail="An error occurred while resetting the password.", ) + # ✅ Email notification: password reset complete + login_link = f"{_base_url(settings)}/accounts/login/" + background_tasks.add_task( + email_sender.send_password_reset_complete_email, # ✅ згадано в умові + str(user.email), + login_link, + ) + return MessageResponseSchema(message="Password reset successfully.") @@ -389,20 +406,14 @@ async def reset_password( 401: { "description": "Unauthorized - Invalid email or password.", "content": { - "application/json": { - "example": { - "detail": "Invalid email or password." - } - } + "application/json": {"example": {"detail": "Invalid email or password."}} }, }, 403: { "description": "Forbidden - User account is not activated.", "content": { "application/json": { - "example": { - "detail": "User account is not activated." - } + "example": {"detail": "User account is not activated."} } }, }, @@ -410,41 +421,18 @@ async def reset_password( "description": "Internal Server Error - An error occurred while processing the request.", "content": { "application/json": { - "example": { - "detail": "An error occurred while processing the request." - } + "example": {"detail": "An error occurred while processing the request."} } }, }, }, ) async def login_user( - login_data: UserLoginRequestSchema, - db: AsyncSession = Depends(get_db), - settings: BaseAppSettings = Depends(get_settings), - jwt_manager: JWTAuthManagerInterface = Depends(get_jwt_auth_manager), + login_data: UserLoginRequestSchema, + db: AsyncSession = Depends(get_db), + settings: BaseAppSettings = Depends(get_settings), + jwt_manager: JWTAuthManagerInterface = Depends(get_jwt_auth_manager), ) -> UserLoginResponseSchema: - """ - Endpoint for user login. - - Authenticates a user using their email and password. - If authentication is successful, creates a new refresh token and returns both access and refresh tokens. - - Args: - login_data (UserLoginRequestSchema): The login credentials. - db (AsyncSession): The asynchronous database session. - settings (BaseAppSettings): The application settings. - jwt_manager (JWTAuthManagerInterface): The JWT authentication manager. - - Returns: - UserLoginResponseSchema: A response containing the access and refresh tokens. - - Raises: - HTTPException: - - 401 Unauthorized if the email or password is invalid. - - 403 Forbidden if the user account is not activated. - - 500 Internal Server Error if an error occurs during token creation. - """ stmt = select(UserModel).filter_by(email=login_data.email) result = await db.execute(stmt) user = result.scalars().first() @@ -467,7 +455,7 @@ async def login_user( refresh_token = RefreshTokenModel.create( user_id=user.id, days_valid=settings.LOGIN_TIME_DAYS, - token=jwt_refresh_token + token=jwt_refresh_token, ) db.add(refresh_token) await db.flush() @@ -495,61 +483,25 @@ async def login_user( responses={ 400: { "description": "Bad Request - The provided refresh token is invalid or expired.", - "content": { - "application/json": { - "example": { - "detail": "Token has expired." - } - } - }, + "content": {"application/json": {"example": {"detail": "Token has expired."}}}, }, 401: { "description": "Unauthorized - Refresh token not found.", "content": { - "application/json": { - "example": { - "detail": "Refresh token not found." - } - } + "application/json": {"example": {"detail": "Refresh token not found."}} }, }, 404: { "description": "Not Found - The user associated with the token does not exist.", - "content": { - "application/json": { - "example": { - "detail": "User not found." - } - } - }, + "content": {"application/json": {"example": {"detail": "User not found."}}}, }, }, ) async def refresh_access_token( - token_data: TokenRefreshRequestSchema, - db: AsyncSession = Depends(get_db), - jwt_manager: JWTAuthManagerInterface = Depends(get_jwt_auth_manager), + token_data: TokenRefreshRequestSchema, + db: AsyncSession = Depends(get_db), + jwt_manager: JWTAuthManagerInterface = Depends(get_jwt_auth_manager), ) -> TokenRefreshResponseSchema: - """ - Endpoint to refresh an access token. - - Validates the provided refresh token, extracts the user ID from it, and issues - a new access token. If the token is invalid or expired, an error is returned. - - Args: - token_data (TokenRefreshRequestSchema): Contains the refresh token. - db (AsyncSession): The asynchronous database session. - jwt_manager (JWTAuthManagerInterface): JWT authentication manager. - - Returns: - TokenRefreshResponseSchema: A new access token. - - Raises: - HTTPException: - - 400 Bad Request if the token is invalid or expired. - - 401 Unauthorized if the refresh token is not found. - - 404 Not Found if the user associated with the token does not exist. - """ try: decoded_token = jwt_manager.decode_refresh_token(token_data.refresh_token) user_id = decoded_token.get("user_id") @@ -578,5 +530,4 @@ async def refresh_access_token( ) new_access_token = jwt_manager.create_access_token({"user_id": user_id}) - return TokenRefreshResponseSchema(access_token=new_access_token) diff --git a/src/routes/profiles.py b/src/routes/profiles.py index 0b7c3420..e4e137cc 100644 --- a/src/routes/profiles.py +++ b/src/routes/profiles.py @@ -1,5 +1,135 @@ -from fastapi import APIRouter +from __future__ import annotations -router = APIRouter() +from uuid import uuid4 -# Write your code here +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError + +from config import get_jwt_auth_manager +from database import get_db +from database.models.accounts import UserModel, UserGroupEnum, UserProfileModel +from schemas.profiles import ProfileCreateSchema, ProfileResponseSchema +from security.http import get_token # твій helper з Bearer parsing +from security.interfaces import JWTAuthManagerInterface +from storages.interfaces import S3StorageInterface +from config.dependencies import get_s3_storage_client # або звідки воно в тебе +from exceptions.storage import StorageError # якщо є; якщо ні — прибери + + +router = APIRouter(prefix="/api/v1", tags=["Profiles"]) + + +@router.post( + "/users/{user_id}/profile/", + response_model=ProfileResponseSchema, + status_code=status.HTTP_201_CREATED, +) +def create_profile( + user_id: int, + form_data_and_file: tuple[ProfileCreateSchema, object] = Depends(ProfileCreateSchema.from_form), + token: str = Depends(get_token), + db: Session = Depends(get_db), + jwt: JWTAuthManagerInterface = Depends(get_jwt_auth_manager), + s3_client: S3StorageInterface = Depends(get_s3_storage_client), +): + data, avatar = form_data_and_file # avatar: UploadFile + + # 1) Token validation (expired/invalid) + try: + payload = jwt.decode_access_token(token) # <-- звір назву у своєму JWTAuthManager + except Exception: + # В ідеалі: розрізнити expired vs invalid якщо в тебе є специфічні ексепшени + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired.", + ) + + current_user_id = int(payload.get("user_id") or payload.get("sub") or 0) + if not current_user_id: + raise HTTPException(status_code=401, detail="Token has expired.") + + # 2) Current user existence/status + current_user: UserModel | None = ( + db.query(UserModel) + .filter(UserModel.id == current_user_id, UserModel.is_active.is_(True)) + .first() + ) + if not current_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or not active.", + ) + + # 3) Authorization rules + is_admin = False + # у тебе може бути current_user.group.name або current_user.group_id — підлаштуй під свою модель + if getattr(current_user, "group", None) is not None: + is_admin = current_user.group.name == UserGroupEnum.ADMIN + if current_user_id != user_id and not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to edit this profile.", + ) + + # 4) Target user existence/status + target_user: UserModel | None = ( + db.query(UserModel) + .filter(UserModel.id == user_id, UserModel.is_active.is_(True)) + .first() + ) + if not target_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or not active.", + ) + + # 5) Check existing profile + existing_profile = ( + db.query(UserProfileModel) + .filter(UserProfileModel.user_id == user_id) + .first() + ) + if existing_profile: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User already has a profile.", + ) + + # 6) Upload avatar to S3/MinIO + avatar_url: str | None = None + try: + ext = (avatar.filename or "avatar").split(".")[-1].lower() + key = f"avatars/{user_id}_{uuid4().hex}.{ext}" + + # ВАЖЛИВО: звір сигнатуру у твоєму S3StorageClient. + # Часто це або upload_file(file=UploadFile, key=str)->str(url) + # або put_object(bucket, key, bytes, content_type)->None + get_url(key)->str + avatar_url = s3_client.upload_file(avatar, key) # <-- підлаштуй під свій інтерфейс + + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to upload avatar. Please try again later.", + ) + + # 7) Create profile and store in DB + profile = UserProfileModel( + user_id=user_id, + first_name=data.first_name.lower(), + last_name=data.last_name.lower(), + gender=data.gender, + date_of_birth=data.date_of_birth, + info=data.info, + avatar=avatar_url, + ) + + try: + db.add(profile) + db.commit() + db.refresh(profile) + except SQLAlchemyError: + db.rollback() + raise + + return profile diff --git a/src/schemas/profiles.py b/src/schemas/profiles.py index adbcbcee..a4f5a773 100644 --- a/src/schemas/profiles.py +++ b/src/schemas/profiles.py @@ -1,13 +1,83 @@ +from __future__ import annotations + from datetime import date +from typing import Optional -from fastapi import UploadFile, Form, File, HTTPException -from pydantic import BaseModel, field_validator, HttpUrl +from fastapi import Form, UploadFile, File +from pydantic import BaseModel, Field, field_validator from validation import ( validate_name, - validate_image, validate_gender, - validate_birth_date + validate_birth_date, + validate_image, ) -# Write your code here + +class ProfileCreateSchema(BaseModel): + first_name: str = Field(..., min_length=1) + last_name: str = Field(..., min_length=1) + gender: str + date_of_birth: date + info: str = Field(..., min_length=1) + + @field_validator("first_name") + @classmethod + def _validate_first_name(cls, v: str) -> str: + return validate_name(v) + + @field_validator("last_name") + @classmethod + def _validate_last_name(cls, v: str) -> str: + return validate_name(v) + + @field_validator("gender") + @classmethod + def _validate_gender(cls, v: str) -> str: + return validate_gender(v) + + @field_validator("date_of_birth") + @classmethod + def _validate_birth_date(cls, v: date) -> date: + return validate_birth_date(v) + + @field_validator("info") + @classmethod + def _validate_info(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("Info cannot be empty.") + return v.strip() + + @classmethod + def from_form( # dependency-friendly + cls, + first_name: str = Form(...), + last_name: str = Form(...), + gender: str = Form(...), + date_of_birth: date = Form(...), + info: str = Form(...), + avatar: UploadFile = File(...), + ) -> tuple["ProfileCreateSchema", UploadFile]: + # Валідація файлу окремо (бо UploadFile не є “чистим” pydantic-типом) + validate_image(avatar) + return cls( + first_name=first_name, + last_name=last_name, + gender=gender, + date_of_birth=date_of_birth, + info=info, + ), avatar + + +class ProfileResponseSchema(BaseModel): + id: int + user_id: int + first_name: str + last_name: str + gender: str + date_of_birth: date + info: str + avatar: Optional[str] = None + + class Config: + from_attributes = True