-
Notifications
You must be signed in to change notification settings - Fork 265
implement functionality of creation, validation profile, add image su… #274
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
77de35e
b937b42
850404d
aeff1e3
2db01ef
8387103
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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, Header, UploadFile, File, Form | ||
| from sqlalchemy import select, delete | ||
| from sqlalchemy.exc import SQLAlchemyError | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
|
|
@@ -31,11 +31,21 @@ | |
| TokenRefreshRequestSchema, | ||
| TokenRefreshResponseSchema | ||
| ) | ||
|
|
||
| from security.interfaces import JWTAuthManagerInterface | ||
|
|
||
| router = APIRouter() | ||
|
|
||
|
|
||
| api_url = "http://127.0.0.1:8000/api/v1/accounts/" | ||
|
|
||
|
|
||
| async def get_user_by_id(id: int, db: AsyncSession): | ||
| stmt = select(UserModel).filter_by(id=id) | ||
| result = await db.execute(stmt) | ||
| return result.scalar_one_or_none() | ||
|
|
||
|
|
||
| @router.post( | ||
| "/register/", | ||
| response_model=UserRegistrationResponseSchema, | ||
|
|
@@ -67,7 +77,9 @@ | |
| ) | ||
| async def register_user( | ||
| user_data: UserRegistrationRequestSchema, | ||
| background_tasks: BackgroundTasks, | ||
| db: AsyncSession = Depends(get_db), | ||
| notificator: EmailSenderInterface = Depends(get_accounts_email_notificator) | ||
| ) -> UserRegistrationResponseSchema: | ||
| """ | ||
| Endpoint for user registration. | ||
|
|
@@ -117,7 +129,12 @@ async def register_user( | |
|
|
||
| activation_token = ActivationTokenModel(user_id=new_user.id) | ||
| db.add(activation_token) | ||
|
|
||
| await db.flush() | ||
| background_tasks.add_task( | ||
| notificator.send_activation_email, | ||
| str(user_data.email), | ||
| f"{api_url}activate/?token={activation_token.token}" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The URL generated for the activation email is for a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The URL generated for account activation is not functional. The This same problem exists for the password reset link generated on line 295. This was a critical issue from the previous review that needs to be resolved for the feature to be usable. |
||
| ) | ||
|
Comment on lines
+133
to
+137
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The activation URL generated here is a GET link containing only a token. However, the |
||
| await db.commit() | ||
| await db.refresh(new_user) | ||
| except SQLAlchemyError as e: | ||
|
|
@@ -162,8 +179,11 @@ async def register_user( | |
| }, | ||
| ) | ||
| async def activate_account( | ||
| activation_data: UserActivationRequestSchema, | ||
| db: AsyncSession = Depends(get_db), | ||
| # token: str, | ||
| background_tasks: BackgroundTasks, | ||
| activation_data: UserActivationRequestSchema, | ||
| notificator: EmailSenderInterface = Depends(get_accounts_email_notificator), | ||
| db: AsyncSession = Depends(get_db), | ||
| ) -> MessageResponseSchema: | ||
| """ | ||
| Endpoint to activate a user's account. | ||
|
|
@@ -185,6 +205,36 @@ async def activate_account( | |
| - 400 Bad Request if the activation token is invalid or expired. | ||
| - 400 Bad Request if the user account is already active. | ||
| """ | ||
| # stmt = ( | ||
| # select(ActivationTokenModel) | ||
| # .where(ActivationTokenModel.token == token) | ||
| # .options(joinedload(ActivationTokenModel.user)) | ||
| # .join(UserModel) | ||
| # ) | ||
| # | ||
| # result = await db.execute(stmt) | ||
| # token_record = result.scalar_one_or_none() | ||
| # now_utc = datetime.now(timezone.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." | ||
| # ) | ||
| # | ||
| # user = token_record.user | ||
| # if user.is_active: | ||
| # raise HTTPException( | ||
| # status_code=status.HTTP_400_BAD_REQUEST, | ||
| # detail="User account is already active." | ||
| # ) | ||
| # | ||
| # user.is_active = True | ||
| # await db.delete(token_record) | ||
| # await db.commit() | ||
|
|
||
| stmt = ( | ||
| select(ActivationTokenModel) | ||
| .options(joinedload(ActivationTokenModel.user)) | ||
|
|
@@ -217,7 +267,11 @@ async def activate_account( | |
| user.is_active = True | ||
| await db.delete(token_record) | ||
| await db.commit() | ||
|
|
||
| background_tasks.add_task( | ||
| notificator.send_activation_complete_email, | ||
| str(user.email), | ||
| f"{api_url}login/" | ||
| ) | ||
| return MessageResponseSchema(message="User account activated successfully.") | ||
|
|
||
|
|
||
|
|
@@ -232,7 +286,9 @@ async def activate_account( | |
| status_code=status.HTTP_200_OK, | ||
| ) | ||
| async def request_password_reset_token( | ||
| background_tasks: BackgroundTasks, | ||
| data: PasswordResetRequestSchema, | ||
| notificator: EmailSenderInterface = Depends(get_accounts_email_notificator), | ||
| db: AsyncSession = Depends(get_db), | ||
| ) -> MessageResponseSchema: | ||
| """ | ||
|
|
@@ -262,6 +318,11 @@ async def request_password_reset_token( | |
| reset_token = PasswordResetTokenModel(user_id=cast(int, user.id)) | ||
| db.add(reset_token) | ||
| await db.commit() | ||
| background_tasks.add_task( | ||
| notificator.send_password_reset_email, | ||
| str(user.email), | ||
| f"{api_url}reset-password/complete/" | ||
|
||
| ) | ||
|
Comment on lines
289
to
+296
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The password reset link sent to the user is missing the required token. The |
||
|
|
||
| return MessageResponseSchema( | ||
| message="If you are registered, you will receive an email with instructions." | ||
|
|
@@ -313,6 +374,8 @@ async def request_password_reset_token( | |
| ) | ||
| async def reset_password( | ||
| data: PasswordResetCompleteRequestSchema, | ||
| background_tasks: BackgroundTasks, | ||
| notificator: EmailSenderInterface = Depends(get_accounts_email_notificator), | ||
| db: AsyncSession = Depends(get_db), | ||
| ) -> MessageResponseSchema: | ||
| """ | ||
|
|
@@ -376,6 +439,11 @@ async def reset_password( | |
| detail="An error occurred while resetting the password." | ||
| ) | ||
|
|
||
| background_tasks.add_task( | ||
| notificator.send_password_reset_complete_email, | ||
| str(data.email), | ||
| f"{api_url}login/" | ||
| ) | ||
| return MessageResponseSchema(message="Password reset successfully.") | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,95 @@ | ||
| from fastapi import APIRouter | ||
| from datetime import date | ||
|
|
||
| from fastapi import APIRouter, Form, UploadFile, File, Depends, HTTPException | ||
| from typing import Annotated | ||
|
|
||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
|
|
||
| from database import get_db | ||
| from config import get_s3_storage_client, get_jwt_auth_manager | ||
| from database.models.accounts import UserProfileModel | ||
| from exceptions import TokenExpiredError, InvalidTokenError, S3FileUploadError | ||
| from routes.accounts import get_user_by_id | ||
| from schemas.profiles import ProfileResponseSchema, ProfileRequestSchema | ||
| from security.headers import api_key_header, validate_api_key | ||
| from security.interfaces import JWTAuthManagerInterface | ||
| from storages import S3StorageInterface | ||
|
|
||
| router = APIRouter() | ||
|
|
||
| # Write your code here | ||
|
|
||
| @router.post( | ||
| "/users/{user_id}/profile/", | ||
| # response_model=TokenRefreshResponseSchema, | ||
|
||
| summary="Create user profile", | ||
| description="Creation user profile with adding avatar", | ||
| response_model=ProfileResponseSchema, | ||
| status_code=201, | ||
| dependencies=[Depends(validate_api_key)] | ||
| ) | ||
| async def create_profile( | ||
| 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(), | ||
| access_token: Annotated[str | None, Depends(api_key_header)] = None, | ||
| db: AsyncSession = Depends(get_db), | ||
| s3_client: S3StorageInterface = Depends(get_s3_storage_client), | ||
| jwt_manager: JWTAuthManagerInterface = Depends(get_jwt_auth_manager) | ||
| ): | ||
| try: | ||
| ProfileRequestSchema( | ||
| first_name=first_name, | ||
| last_name=last_name, | ||
| gender=gender, | ||
| date_of_birth=date_of_birth, | ||
| info=info, | ||
| avatar=avatar | ||
| ) | ||
| except Exception as exc: | ||
|
||
| raise HTTPException(status_code=422, detail=exc.errors()[0]["msg"]) | ||
| try: | ||
| decoded_auth_header = access_token.split() | ||
| user_jwt = jwt_manager.decode_access_token(decoded_auth_header[1]) | ||
| current_user = await get_user_by_id(user_jwt["user_id"], db) | ||
| query_user = await get_user_by_id(user_id, db) | ||
|
|
||
| if query_user.profile: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a potential |
||
| raise HTTPException(status_code=400, detail="User already has a profile.") | ||
|
|
||
| if current_user.id != user_id and current_user.group_id == 1: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using a magic number like |
||
| raise HTTPException(status_code=403, detail="You don't have permission to edit this profile.") | ||
|
|
||
| if not query_user or not query_user.is_active: | ||
| raise HTTPException(status_code=401, detail="User not found or not active.") | ||
|
|
||
| try: | ||
| avatar_bytes = avatar.file.read() | ||
| file_name = f"avatars/{user_id}_avatar.jpg" | ||
|
||
| await s3_client.upload_file(file_name, avatar_bytes) | ||
|
|
||
| avatar_url = await s3_client.get_file_url(file_name) | ||
| profile_db = UserProfileModel( | ||
| first_name=first_name.lower(), | ||
| last_name=last_name.lower(), | ||
| gender=gender, | ||
| date_of_birth=date_of_birth, | ||
| info=info, | ||
| avatar=file_name, | ||
| user=query_user | ||
| ) | ||
| db.add(profile_db) | ||
| await db.commit() | ||
| profile_db.avatar = avatar_url | ||
| return profile_db | ||
| except S3FileUploadError: | ||
| raise HTTPException(status_code=500, detail="Failed to upload avatar. Please try again later.") | ||
| except Exception as exc: | ||
| raise HTTPException(status_code=500, detail=str(exc)) | ||
| except TokenExpiredError: | ||
| raise HTTPException(status_code=401, detail="Token has expired.") | ||
| except InvalidTokenError: | ||
| raise HTTPException(status_code=401, detail="Token has invalid") | ||
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,59 @@ | ||
| from datetime import date | ||
| from typing import Any | ||
|
|
||
| from fastapi import UploadFile, Form, File, HTTPException | ||
| from pydantic import BaseModel, field_validator, HttpUrl | ||
| from pydantic import BaseModel, field_validator, HttpUrl, ConfigDict | ||
| from typing_extensions import Self | ||
|
|
||
| from database.models.accounts import GenderEnum | ||
| from validation import ( | ||
| validate_name, | ||
| validate_image, | ||
| validate_gender, | ||
| validate_birth_date | ||
| ) | ||
| from validation.profile import validate_info | ||
|
|
||
| # Write your code here | ||
|
|
||
| class ProfileBaseSchema(BaseModel): | ||
| first_name: str | ||
| last_name: str | ||
| gender: str | ||
| date_of_birth: date | ||
| info: str | ||
|
|
||
|
|
||
| class ProfileRequestSchema(ProfileBaseSchema): | ||
| avatar: UploadFile | ||
|
|
||
| @field_validator("first_name", "last_name") | ||
| @classmethod | ||
| def validate_first_last_name(cls, value): | ||
| validate_name(value) | ||
|
|
||
| @field_validator("gender") | ||
| @classmethod | ||
| def validate_gender(cls, value): | ||
| validate_gender(value) | ||
|
|
||
| @field_validator("date_of_birth") | ||
| @classmethod | ||
| def validate_date_of_birth(cls, value): | ||
| validate_birth_date(value) | ||
|
|
||
| @field_validator("info") | ||
| @classmethod | ||
| def validate_info(cls, value): | ||
| validate_info(value) | ||
|
|
||
| @field_validator("avatar") | ||
| @classmethod | ||
| def validate_avatar(cls, value): | ||
| validate_image(value) | ||
|
Comment on lines
+29
to
+56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All the |
||
|
|
||
|
|
||
| class ProfileResponseSchema(ProfileBaseSchema): | ||
| model_config = ConfigDict(from_attributes=True) | ||
| id: int | ||
| user_id: int | ||
| avatar: str | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| from fastapi import Security, HTTPException | ||
| from fastapi.security import APIKeyHeader | ||
|
|
||
| api_key_header = APIKeyHeader(name="Authorization", auto_error=False) | ||
|
|
||
|
|
||
| async def validate_api_key(api_key: str = Security(api_key_header)): | ||
|
|
||
| if not api_key: | ||
| raise HTTPException( | ||
| status_code=401, | ||
| detail="Authorization header is missing" | ||
| ) | ||
|
|
||
| decoded_auth_header = api_key.split() | ||
| if decoded_auth_header[0] != "Bearer" or len(decoded_auth_header) != 2: | ||
| raise HTTPException(status_code=401, detail="Invalid Authorization header format. Expected 'Bearer <token>'") | ||
|
|
||
| return api_key |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using
lazy="joined"is a valid performance optimization that avoids the N+1 query problem by fetching the user's profile in the same query as the user. However, this means aJOINwill be performed every time aUserModelis loaded, even in scenarios where the profile data isn't needed (like during login). For frequently accessed one-to-one relationships this is fine, but it's good to be aware of the trade-off. The defaultlazy="select"would load the profile only when accessed.