-
Notifications
You must be signed in to change notification settings - Fork 265
Solution #262
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?
Solution #262
Changes from 2 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 |
|---|---|---|
|
|
@@ -40,6 +40,8 @@ WORKDIR /usr/src/fastapi | |
| # Copy the source code | ||
| COPY ./src . | ||
|
|
||
| COPY ./alembic.ini /usr/src/fastapi/alembic.ini | ||
|
||
|
|
||
|
||
| # Copy commands | ||
| COPY ./commands /commands | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,11 +4,15 @@ services: | |
| context: . | ||
| dockerfile: ./docker/tests/Dockerfile | ||
| container_name: backend_theater_test | ||
| command: [ "pytest", "-c", "/usr/src/config/pytest.ini", | ||
| "-m", "e2e", "--maxfail=5", "--disable-warnings", "-v", "--tb=short"] | ||
| command: > | ||
|
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 command is executed via |
||
| sh -c " | ||
| alembic upgrade head && | ||
|
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. You run |
||
| pytest -c /usr/src/config/pytest.ini -m e2e --maxfail=5 --disable-warnings -v --tb=short | ||
|
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 pytest command uses |
||
| " | ||
| environment: | ||
| - PYTHONPATH=/usr/src/fastapi | ||
| - ENVIRONMENT=testing | ||
| - ALEMBIC_CONFIG=/usr/src/config/alembic.ini | ||
| - EMAIL_HOST=mailhog_theater_test | ||
| - EMAIL_PORT=1025 | ||
| - EMAIL_HOST_USER=testuser@mate.com | ||
|
|
@@ -27,6 +31,7 @@ services: | |
| condition: service_healthy | ||
| volumes: | ||
| - ./src:/usr/src/fastapi | ||
| - ./alembic.ini:/usr/src/config/alembic.ini | ||
|
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. You mount |
||
| networks: | ||
| - theater_network_test | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,11 +48,30 @@ class Settings(BaseAppSettings): | |
| JWT_SIGNING_ALGORITHM: str = os.getenv("JWT_SIGNING_ALGORITHM", "HS256") | ||
|
|
||
|
|
||
| # class TestingSettings(BaseAppSettings): | ||
| # SECRET_KEY_ACCESS: str = "SECRET_KEY_ACCESS" | ||
| # SECRET_KEY_REFRESH: str = "SECRET_KEY_REFRESH" | ||
| # JWT_SIGNING_ALGORITHM: str = "HS256" | ||
| # | ||
| # def model_post_init(self, __context: dict[str, Any] | None = None) -> None: | ||
| # object.__setattr__(self, 'PATH_TO_DB', ":memory:") | ||
| # object.__setattr__( | ||
| # self, | ||
| # 'PATH_TO_MOVIES_CSV', | ||
| # str(self.BASE_DIR / "database" / "seed_data" / "test_data.csv") | ||
| # ) | ||
|
||
|
|
||
| class TestingSettings(BaseAppSettings): | ||
| SECRET_KEY_ACCESS: str = "SECRET_KEY_ACCESS" | ||
| SECRET_KEY_REFRESH: str = "SECRET_KEY_REFRESH" | ||
| JWT_SIGNING_ALGORITHM: str = "HS256" | ||
|
|
||
| POSTGRES_USER: str = "admin" | ||
| POSTGRES_PASSWORD: str = "some_password" | ||
| POSTGRES_HOST: str = "postgres_theater" | ||
| POSTGRES_DB_PORT: int = 5432 | ||
| POSTGRES_DB: str = "movies_db" | ||
|
||
|
|
||
| def model_post_init(self, __context: dict[str, Any] | None = None) -> None: | ||
| object.__setattr__(self, 'PATH_TO_DB', ":memory:") | ||
| object.__setattr__( | ||
|
|
||
| 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 | ||
|
|
@@ -33,6 +33,9 @@ | |
| ) | ||
| from security.interfaces import JWTAuthManagerInterface | ||
|
|
||
| from database.session_postgresql import get_postgresql_db | ||
|
||
| from notifications.emails import EmailSender | ||
|
||
|
|
||
| router = APIRouter() | ||
|
|
||
|
|
||
|
|
@@ -67,7 +70,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 +125,13 @@ async def register_user( | |
|
|
||
| await db.commit() | ||
| await db.refresh(new_user) | ||
| activation_link = f"http://127.0.0.1/accounts/activate/?token={activation_token.token}&email={user_data.email}" | ||
|
|
||
| background_tasks.add_task( | ||
| email_sender.send_activation_email, | ||
| new_user.email, | ||
| activation_link, | ||
| ) | ||
| except SQLAlchemyError as e: | ||
| await db.rollback() | ||
| raise HTTPException( | ||
|
|
@@ -233,7 +245,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,6 +276,12 @@ async def request_password_reset_token( | |
| reset_token = PasswordResetTokenModel(user_id=cast(int, user.id)) | ||
| db.add(reset_token) | ||
| await db.commit() | ||
| reset_link = f"http://127.0.0.1/accounts/reset-password/complete/?token={reset_token.token}&email={user.email}" | ||
|
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.
|
||
| background_tasks.add_task( | ||
| email_sender.send_password_reset_email, | ||
| user.email, | ||
| reset_link | ||
| ) | ||
|
|
||
| return MessageResponseSchema( | ||
| message="If you are registered, you will receive an email with instructions." | ||
|
|
@@ -313,7 +333,10 @@ async def request_password_reset_token( | |
| ) | ||
| async def reset_password( | ||
| data: PasswordResetCompleteRequestSchema, | ||
| background_tasks: BackgroundTasks, | ||
|
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 |
||
| db: AsyncSession = Depends(get_db), | ||
| email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator), | ||
|
|
||
| ) -> MessageResponseSchema: | ||
| """ | ||
| Endpoint for resetting a user's password. | ||
|
|
@@ -360,6 +383,7 @@ async def reset_password( | |
| if expires_at < datetime.now(timezone.utc): | ||
| await db.run_sync(lambda s: s.delete(token_record)) | ||
| await db.commit() | ||
|
|
||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail="Invalid email or token." | ||
|
|
@@ -369,6 +393,13 @@ async def reset_password( | |
| user.password = data.password | ||
| await db.run_sync(lambda s: s.delete(token_record)) | ||
| await db.commit() | ||
|
|
||
| login_link = "http://127.0.0.1/accounts/login/" | ||
| background_tasks.add_task( | ||
| email_sender.send_password_reset_complete_email, | ||
| user.email, | ||
| login_link | ||
| ) | ||
| except SQLAlchemyError: | ||
| await db.rollback() | ||
| raise HTTPException( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,173 @@ | ||
| from fastapi import APIRouter | ||
| from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, BackgroundTasks | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
| from sqlalchemy import select | ||
| from config.settings import Settings | ||
|
||
|
|
||
| from database import get_db, ActivationTokenModel, UserGroupEnum | ||
| from database.models.accounts import UserModel, UserProfileModel | ||
| from schemas.profiles import ProfileCreateSchema, ProfileResponseSchema | ||
| from schemas.accounts import UserActivationRequestSchema, MessageResponseSchema | ||
| from security.token_manager import JWTAuthManager | ||
| from storages.interfaces import S3StorageInterface | ||
| from config.dependencies import get_s3_storage_client, get_accounts_email_notificator | ||
| from validation.profile import validate_image | ||
| from notifications.interfaces import EmailSenderInterface | ||
| from fastapi.security import OAuth2PasswordBearer | ||
| import os | ||
|
|
||
| router = APIRouter() | ||
|
|
||
| # Write your code here | ||
| oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/accounts/login/") | ||
|
|
||
| token_manager = JWTAuthManager( | ||
| secret_key_access=os.getenv("SECRET_KEY_ACCESS", "default_access_secret"), | ||
| secret_key_refresh=os.getenv("SECRET_KEY_REFRESH", "default_refresh_secret"), | ||
| algorithm=os.getenv("JWT_SIGNING_ALGORITHM", "HS256"), | ||
| ) | ||
|
||
|
|
||
| async def get_current_user( | ||
| token: str = Depends(oauth2_scheme), | ||
| db: AsyncSession = Depends(get_db), | ||
| ) -> UserModel: | ||
| try: | ||
| payload = token_manager.decode_access_token(token) | ||
| user_id = payload.get("sub") | ||
| if not user_id: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_401_UNAUTHORIZED, | ||
| detail="Invalid token: missing subject" | ||
| ) | ||
|
|
||
| stmt = select(UserModel).where(UserModel.id == user_id) | ||
| result = await db.execute(stmt) | ||
| user = result.scalars().first() | ||
|
|
||
| if not user: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_404_NOT_FOUND, | ||
| detail="User not found" | ||
| ) | ||
|
|
||
| return user | ||
| except Exception: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_401_UNAUTHORIZED, | ||
| detail="Invalid or expired token" | ||
|
||
| ) | ||
|
|
||
| @router.post("/profiles/", response_model=ProfileResponseSchema) | ||
| async def create_profile( | ||
| profile: ProfileCreateSchema, | ||
| db: AsyncSession = Depends(get_db), | ||
| current_user: UserModel = Depends(get_current_user), | ||
| ): | ||
| stmt = select(UserProfileModel).where(UserProfileModel.user_id == current_user.id) | ||
| result = await db.execute(stmt) | ||
| existing_profile = result.scalars().first() | ||
| if existing_profile: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail="Profile already exists." | ||
| ) | ||
|
|
||
| new_profile = UserProfileModel( | ||
| user_id=current_user.id, | ||
| first_name=profile.first_name, | ||
| last_name=profile.last_name, | ||
| gender=profile.gender, | ||
| date_of_birth=profile.date_of_birth, | ||
| info=profile.info, | ||
| ) | ||
| db.add(new_profile) | ||
| await db.commit() | ||
| await db.refresh(new_profile) | ||
|
|
||
| return new_profile | ||
|
||
|
|
||
|
|
||
|
|
||
| @router.post("/users/{user_id}/profile/", | ||
| response_model=ProfileResponseSchema, | ||
| status_code=status.HTTP_201_CREATED | ||
| ) | ||
| async def create_user_profile( | ||
| user_id: int, | ||
| profile_data: ProfileCreateSchema = Depends(), | ||
| avatar: UploadFile | None = None, | ||
| db: AsyncSession = Depends(get_db), | ||
| current_user: UserModel = Depends(get_current_user), | ||
| s3_client: S3StorageInterface = Depends(get_s3_storage_client), | ||
| ): | ||
| if current_user.id != user_id: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_403_FORBIDDEN, | ||
| detail="Not authenticated to create profile for another user." | ||
|
||
| ) | ||
|
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 task requires checking if the |
||
|
|
||
| stmt = select(UserProfileModel).where(UserProfileModel.user_id == user_id) | ||
| result = await db.execute(stmt) | ||
| existing_profile = result.scalars().first() | ||
| if existing_profile: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail="Profile already exists." | ||
| ) | ||
|
|
||
| avatar_url = None | ||
| if avatar: | ||
| validate_image(avatar) | ||
| avatar_key = f"avatars/{user_id}_avatar.jpg" | ||
| await s3_client.upload_fileobj(avatar.file, avatar_key) | ||
| avatar_url = await s3_client.get_file_url(avatar_key) | ||
|
||
|
|
||
| new_profile = UserProfileModel( | ||
| user_id=user_id, | ||
| first_name=profile_data.first_name, | ||
| last_name=profile_data.last_name, | ||
| gender=profile_data.gender, | ||
| date_of_birth=profile_data.date_of_birth, | ||
| info=profile_data.info, | ||
| avatar=avatar_url, | ||
| ) | ||
| db.add(new_profile) | ||
| await db.commit() | ||
| await db.refresh(new_profile) | ||
|
|
||
| return new_profile | ||
|
|
||
|
|
||
| @router.post( | ||
| "/accounts/activate/", | ||
| response_model=MessageResponseSchema, | ||
| status_code=status.HTTP_200_OK, | ||
| ) | ||
| async def activate_account( | ||
| data: UserActivationRequestSchema, | ||
| background_tasks: BackgroundTasks, | ||
| db: AsyncSession = Depends(get_db), | ||
| email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator), | ||
| ): | ||
| stmt = ( | ||
| select(ActivationTokenModel) | ||
| .join(UserModel) | ||
| .where(UserModel.email == data.email, ActivationTokenModel.token == data.token) | ||
| ) | ||
| result = await db.execute(stmt) | ||
| activation_token = result.scalars().first() | ||
| if not activation_token: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_404_NOT_FOUND, | ||
| detail="Invalid or expired activation token." | ||
| ) | ||
|
|
||
| user = activation_token.user | ||
| user.is_active = True | ||
| await db.commit() | ||
|
|
||
| background_tasks.add_task( | ||
| email_sender.send_activation_complete_email, | ||
| email=user.email, | ||
| login_link="https://example.com/login", | ||
| ) | ||
|
|
||
| return {"message": "User account activated successfully."} | ||
|
||
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.
This line duplicates an earlier
COPY ./alembic.ini(you previously copied it to/usr/src/alembic/alembic.ini). If you needalembic.iniin both locations keep it, otherwise remove this redundant copy to avoid confusion.