-
Notifications
You must be signed in to change notification settings - Fork 264
feat: add email notification & profile endpoint #268
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 all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
Large diffs are not rendered by default.
| 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: | ||
| 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: | ||
|
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. Similar to the token decoding, catching a generic |
||
| 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 | ||
| ) | ||
| 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] |
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.
Catching a generic
Exceptionhere 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, inroutes/accounts.py,BaseSecurityErroris used for handling token decoding errors.