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 @@ -6,7 +6,7 @@ COPY pyproject.toml pyproject.toml

RUN pip install poetry
RUN poetry config virtualenvs.create false
RUN poetry install --no-dev
RUN poetry install --no-root

COPY . /app

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""create users and user_addresses tables

Revision ID: 4ad2b65b1fb4
Revises: cedcfebfb6ad
Create Date: 2025-09-01 13:38:14.539433

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '4ad2b65b1fb4'
down_revision: Union[str, None] = 'cedcfebfb6ad'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""create users and user_addresses tables

Revision ID: b26b69756757
Revises: 4ad2b65b1fb4
Create Date: 2025-09-01 13:41:42.991944

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'b26b69756757'
down_revision: Union[str, None] = '4ad2b65b1fb4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=True),
sa.Column('phone_number', sa.String(), nullable=True),
sa.Column('hashed_password', sa.String(), nullable=True),
sa.Column('is_admin', sa.Boolean(), nullable=True),
sa.Column('is_staff', sa.Boolean(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('first_name', sa.String(), nullable=True),
sa.Column('last_name', sa.String(), nullable=True),
sa.Column('date_joined', sa.DateTime(), nullable=True),
sa.Column('last_login', sa.DateTime(), nullable=True),
sa.Column('is_temporary', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_phone_number'), 'users', ['phone_number'], unique=True)
op.create_table('user_addresses',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(), nullable=True),
sa.Column('city', sa.String(), nullable=True),
sa.Column('street', sa.String(), nullable=True),
sa.Column('house', sa.String(), nullable=True),
sa.Column('apartment', sa.String(), nullable=True),
sa.Column('post_code', sa.String(), nullable=True),
sa.Column('floor', sa.String(), nullable=True),
sa.Column('additional_info', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_addresses_id'), 'user_addresses', ['id'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_user_addresses_id'), table_name='user_addresses')
op.drop_table('user_addresses')
op.drop_index(op.f('ix_users_phone_number'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ services:
image: postgres:13
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
Expand Down
8 changes: 7 additions & 1 deletion src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from src.common.databases.postgres import postgres
from src.general.views import router as status_router
from src.routes import BaseRoutesPrefixes
from src.users.views import user_router
from src.users.views import user_router, user_address_router

def include_routes(application: FastAPI) -> None:
application.include_router(
Expand All @@ -30,6 +30,12 @@ def include_routes(application: FastAPI) -> None:
tags=['Account'],
)

application.include_router(
router=user_address_router,
prefix=BaseRoutesPrefixes.account,
tags=['Account'],
)

def get_application() -> FastAPI:
application = FastAPI(
debug=base_settings.debug,
Expand Down
2 changes: 1 addition & 1 deletion src/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ class BaseRoutesPrefixes:
openapi: str = '/openapi.json'

catalogue: str = '/catalogue'
authentication: str = '/auth'
authentication: str = ''
account: str = '/account'
40 changes: 37 additions & 3 deletions src/users/models/pydantic.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Union
from typing import Union, Optional

from pydantic import (
BaseModel,
Expand All @@ -11,11 +11,45 @@ class UserModel(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: Union[int, None] = None
first_name: str
last_name: str
first_name: Optional[str] = None
last_name: Optional[str] = None
email: EmailStr
phone_number: str


class UserWithPassword(UserModel):
hashed_password: str

class UserAddressModel(BaseModel):
"""UserAddress Pydantic model matching the SQLAlchemy model."""
model_config = ConfigDict(from_attributes=True)

id: Union[int, None] = None
user_id: int
title: Optional[str] = None
city: Optional[str] = None
street: Optional[str] = None
house: Optional[str] = None
apartment: Optional[str] = None
post_code: Optional[str] = None
floor: Optional[str] = None
additional_info: Optional[str] = None

class UserAddressBriefModel(BaseModel):
"""Brief model for API list responses (id + title only)"""
model_config = ConfigDict(from_attributes=True)
id: Union[int, None] = None
title: Optional[str] = None

class UserAddressDetailModel(BaseModel):
"""Detail model for API responses (all fields except user_id)"""
model_config = ConfigDict(from_attributes=True)
id: Union[int, None] = None
title: Optional[str] = None
city: str
street: str
house: str
apartment: Optional[str] = None
post_code: Optional[str] = None
floor: Optional[str] = None
additional_info: Optional[str] = None
12 changes: 11 additions & 1 deletion src/users/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
from src.users.models.pydantic import (
UserModel,
UserWithPassword,
UserAddressModel,
)
from src.users.models.sqlalchemy import User
from src.users.models.sqlalchemy import User, UserAddress


class UserRepository(BaseSQLAlchemyRepository[User, UserModel]):
Expand All @@ -35,3 +36,12 @@ async def get_by_email(self, email: str) -> Optional[UserWithPassword]:

def get_user_repository(session: AsyncSession = Depends(get_session)) -> UserRepository:
return UserRepository(session=session)


class UserAddressRepository(BaseSQLAlchemyRepository[UserAddress, UserAddressModel]):
def __init__(self, session: AsyncSession):
super().__init__(model=UserAddress, pydantic_model=UserAddressModel, session=session)


def get_user_address_repository(session: AsyncSession = Depends(get_session)) -> UserAddressRepository:
return UserAddressRepository(session=session)
1 change: 1 addition & 0 deletions src/users/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

class UserManagementRoutesPrefixes:
user: str = '/user'
address: str = '/address'


class UserRoutesPrefixes(BaseCrudPrefixes):
Expand Down
17 changes: 17 additions & 0 deletions src/users/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
from src.common.service import BaseService
from src.users.models.pydantic import (
UserModel,
UserAddressModel
)
from src.users.repository import (
UserRepository,
get_user_repository,
UserAddressRepository,
get_user_address_repository,
)


Expand All @@ -31,3 +34,17 @@ async def authenticate(self, email: str, password: str) -> Optional[UserModel]:

def get_user_service(repo: UserRepository = Depends(get_user_repository)) -> UserService:
return UserService(repository=repo)

class UserAddressService(BaseService[UserAddressModel]):
def __init__(self, repository: UserAddressRepository):
super().__init__(repository)

async def get_addresses_list(self, user_id: int):
return await self.repository.filter(user_id=user_id)

async def get_address_detail(self, address_id: int):
"""Get specific address details"""
return await self.detail(pk=address_id)

def get_user_address_service(repo: UserAddressRepository = Depends(get_user_address_repository)) -> UserAddressService:
return UserAddressService(repository=repo)
1 change: 1 addition & 0 deletions src/users/views/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .user import router as user_router
from .user import address_router as user_address_router
53 changes: 50 additions & 3 deletions src/users/views/user.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
from typing import (
Annotated,
Union,
Union, List
)

from fastapi import (
APIRouter,
Depends,
status,
Response
)

from src.common.exceptions.base import ObjectDoesNotExistException
from src.authentication.utils import get_current_user
from src.common.schemas.common import ErrorResponse
from src.users.models.pydantic import (
UserModel,
UserModel, UserAddressBriefModel, UserAddressDetailModel,
)
from src.users.routes import (
UserManagementRoutesPrefixes,
UserRoutesPrefixes,
)

from src.users.services import UserAddressService, get_user_address_service

router = APIRouter(prefix=UserManagementRoutesPrefixes.user)
address_router = APIRouter(prefix=UserManagementRoutesPrefixes.address)


@router.get(
Expand All @@ -43,3 +46,47 @@ async def user_detail(
"""

return current_user

@address_router.get(
UserRoutesPrefixes.root,
responses={
status.HTTP_200_OK: {'model': UserAddressBriefModel},
status.HTTP_404_NOT_FOUND: {'model': ErrorResponse}
},
status_code=status.HTTP_200_OK,
response_model=List[UserAddressBriefModel],
)
async def user_addresses_list(
current_user: Annotated[UserModel, Depends(get_current_user)],
address_service: Annotated[UserAddressService, Depends(get_user_address_service)],
) -> List[UserAddressBriefModel]:
addresses = await address_service.get_addresses_list(user_id=current_user.id)
return [
UserAddressBriefModel(
id=address.id,
title=address.title
)
for address in addresses
]

@address_router.get(
UserRoutesPrefixes.detail,
responses={
status.HTTP_200_OK: {'model': UserAddressDetailModel},
status.HTTP_404_NOT_FOUND: {'model': ErrorResponse}
},
status_code=status.HTTP_200_OK,
response_model=Union[UserAddressDetailModel, ErrorResponse],
)
async def user_address_details(
response: Response,
address_id: int,
address_service: Annotated[UserAddressService, Depends(get_user_address_service)],
) -> Union[Response, ErrorResponse]:
try:
response = await address_service.get_address_detail(address_id=address_id)
except ObjectDoesNotExistException as exc:
response.status_code = status.HTTP_404_NOT_FOUND
return ErrorResponse(message=exc.message)

return response