Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ WORKDIR /usr/src/poetry

# Install dependencies with Poetry
RUN poetry lock
RUN poetry install --no-root --only main
RUN poetry install --no-root

# Selecting a working directory
WORKDIR /usr/src/fastapi
Expand Down
9 changes: 7 additions & 2 deletions docker-compose-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The command is executed via sh -c " ... ". This is fine, but confirm the working directory and mounted files inside the test image so both Alembic and pytest find their configs and the application code as expected.

sh -c "
alembic upgrade head &&

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You run alembic upgrade head before running pytest. This is correct in principle, but because ALEMBIC_CONFIG path is incorrect (see comment on line 15) this step will likely fail. Also ensure the database is reachable/ready before running Alembic (consider a short wait or a retry loop).

pytest -c /usr/src/config/pytest.ini -m e2e --maxfail=5 --disable-warnings -v --tb=short

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pytest command uses -c /usr/src/config/pytest.ini. Ensure pytest.ini actually exists at that path inside the container (either include it in the image or mount it). Otherwise pytest will run with default settings or fail to load the expected configuration.

"
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
Expand All @@ -27,6 +31,7 @@ services:
condition: service_healthy
volumes:
- ./src:/usr/src/fastapi
- ./alembic.ini:/usr/src/config/alembic.ini

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mount ./alembic.ini to /usr/src/config/alembic.ini here — that does not match the ALEMBIC_CONFIG env value on line 15. Align the mounted destination and ALEMBIC_CONFIG value so Alembic can locate the config file.

networks:
- theater_network_test

Expand Down
19 changes: 17 additions & 2 deletions docker/tests/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ FROM python:3.10-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_NO_CACHE_DIR=off
ENV ALEMBIC_CONFIG=/usr/src/config/alembic.ini

# Installing dependencies
RUN apt update
RUN apt update && apt install -y \
gcc \
libpq-dev \
netcat-openbsd \
postgresql-client \
dos2unix \
&& apt clean

# Install Poetry
RUN python -m pip install --upgrade pip && \
Expand All @@ -27,10 +34,18 @@ WORKDIR /usr/src/poetry

# Install dependencies with Poetry
RUN poetry lock
RUN poetry install --no-root --only main
RUN poetry install --no-root

# Selecting a working directory
WORKDIR /usr/src/fastapi

# Copy the source code
COPY ./src .

COPY ./alembic.ini /usr/src/config/alembic.ini

COPY ./commands /commands
RUN dos2unix /commands/*.sh && chmod +x /commands/*.sh

CMD ["pytest", "-c", "/usr/src/config/pytest.ini", "-m", "e2e", "--maxfail=5", "--disable-warnings", "-v", "--tb=short"]

152 changes: 45 additions & 107 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ asyncpg = "^0.30.0"
aiosqlite = "^0.21.0"
aioboto3 = "^13.4.0"
pytest-asyncio = "^0.25.3"

pygments = "*"

[build-system]
requires = ["poetry-core"]
Expand Down
1 change: 1 addition & 0 deletions src/config/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,4 @@ def get_s3_storage_client(
secret_key=settings.S3_STORAGE_SECRET_KEY,
bucket_name=settings.S3_BUCKET_NAME
)

21 changes: 21 additions & 0 deletions src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,37 @@ class Settings(BaseAppSettings):
SECRET_KEY_REFRESH: str = os.getenv("SECRET_KEY_REFRESH", os.urandom(32))
JWT_SIGNING_ALGORITHM: str = os.getenv("JWT_SIGNING_ALGORITHM", "HS256")

@property
def DATABASE_URL(self) -> str:
return (
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_DB_PORT}"
)

class TestingSettings(BaseAppSettings):
SECRET_KEY_ACCESS: str = "SECRET_KEY_ACCESS"
SECRET_KEY_REFRESH: str = "SECRET_KEY_REFRESH"
JWT_SIGNING_ALGORITHM: str = "HS256"

DATABASE_URL: str = "sqlite+aiosqlite:///./test.db"

EMAIL_HOST: str = "localhost"
EMAIL_PORT: int = 1025
EMAIL_HOST_USER: str = "testuser@mate.com"
EMAIL_HOST_PASSWORD: str = "test_password"
EMAIL_USE_TLS: bool = False
MAILHOG_API_PORT: int = 8025

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")
)

environment = os.getenv('ENVIRONMENT', 'developing')
if environment == 'testing':
settings = TestingSettings()
else:
settings = Settings()
26 changes: 14 additions & 12 deletions src/database/migrations/env.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from logging.config import fileConfig

from alembic import context
from sqlalchemy import create_engine

from config import get_settings
from database.models import movies, accounts # noqa: F401
from database.models.base import Base
from database.session_postgresql import sync_postgresql_engine


# this is the Alembic Config object, which provides
Expand All @@ -26,7 +27,10 @@
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
settings = get_settings()
DATABASE_URL = settings.DATABASE_URL.replace("+aiosqlite", "")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line will raise an AttributeError when running migrations in a development environment. The Settings class in src/config/settings.py (used for development) does not define a DATABASE_URL attribute. You'll need to add this attribute to the Settings class, likely as a property that constructs the PostgreSQL connection string from the other POSTGRES_* variables.


connectable = create_engine(DATABASE_URL)

def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
Expand All @@ -40,18 +44,17 @@ def run_migrations_offline() -> None:
script output.

"""
connectable = sync_postgresql_engine

with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True
)
context.configure(
url=DATABASE_URL,
target_metadata=target_metadata,
literal_binds=True,
compare_type=True,
compare_server_default=True
)

with context.begin_transaction():
context.run_migrations()
with context.begin_transaction():
context.run_migrations()


def run_migrations_online() -> None:
Expand All @@ -61,7 +64,6 @@ def run_migrations_online() -> None:
and associate a connection with the context.

"""
connectable = sync_postgresql_engine

with connectable.connect() as connection:
context.configure(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@


def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == 'sqlite':
return

# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('activation_tokens', 'token',
existing_type=sa.VARCHAR(length=255),
Expand Down
18 changes: 13 additions & 5 deletions src/database/migrations/versions/32b1054a69e3_initial_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,20 @@

# revision identifiers, used by Alembic.
revision: str = '32b1054a69e3'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = None
branch_labels: str | None = None
depends_on: str | None = None


def upgrade() -> None:
bind = op.get_bind()
dialect = bind.dialect.name

if dialect == 'sqlite':
default_timestamp = sa.text('CURRENT_TIMESTAMP')
else:
default_timestamp = sa.func.now()

# ### commands auto generated by Alembic - please adjust! ###
op.create_table('actors',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
Expand Down Expand Up @@ -70,8 +78,8 @@ def upgrade() -> None:
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=default_timestamp, nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=default_timestamp, nullable=False),
sa.Column('group_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['user_groups.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@


def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == 'sqlite':
return

# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('refresh_tokens', 'token',
existing_type=sa.VARCHAR(length=64),
Expand All @@ -28,6 +32,10 @@ def upgrade() -> None:


def downgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == 'sqlite':
return

# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('refresh_tokens', 'token',
existing_type=sa.String(length=512),
Expand Down
49 changes: 44 additions & 5 deletions src/routes/accounts.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
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
Expand Down Expand Up @@ -67,7 +68,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.
Expand Down Expand Up @@ -101,10 +104,10 @@ async def register_user(
result = await db.execute(stmt)
user_group = result.scalars().first()
if not user_group:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Default user group not found."
)
user_group = UserGroupModel(name=UserGroupEnum.USER)
db.add(user_group)
await db.commit()
await db.refresh(user_group)

try:
new_user = UserModel.create(
Expand All @@ -120,6 +123,13 @@ async def register_user(

await db.commit()
await db.refresh(new_user)
activation_link = f"http://127.0.0.1/api/v1/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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -217,6 +229,14 @@ async def activate_account(
user.is_active = True
await db.delete(token_record)
await db.commit()
await db.refresh(user)

login_link = "http://127.0.0.1/login/"
background_tasks.add_task(
email_sender.send_activation_complete_email,
user.email,
login_link
)

return MessageResponseSchema(message="User account activated successfully.")

Expand All @@ -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.
Expand Down Expand Up @@ -262,6 +284,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}"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request_password_reset_token uses BackgroundTasks and the injected email_sender correctly — keep this pattern. However note earlier comment: replace get_postgresql_db with get_db for the db dependency to meet the DI requirement.

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."
Expand Down Expand Up @@ -313,7 +341,10 @@ async def request_password_reset_token(
)
async def reset_password(
data: PasswordResetCompleteRequestSchema,
background_tasks: BackgroundTasks,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reset_password endpoint properly schedules the password-reset-complete email via BackgroundTasks and email_sender. Ensure consistency by also switching the db dependency here to get_db (instead of get_postgresql_db).

db: AsyncSession = Depends(get_db),
email_sender: EmailSenderInterface = Depends(get_accounts_email_notificator),

) -> MessageResponseSchema:
"""
Endpoint for resetting a user's password.
Expand Down Expand Up @@ -360,6 +391,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."
Expand All @@ -369,6 +401,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(
Expand Down
Loading
Loading