From 244228ece2450129f92135cb75f5bbe3e419d51e Mon Sep 17 00:00:00 2001 From: skarakash Date: Mon, 1 Sep 2025 18:48:00 +0300 Subject: [PATCH 1/4] db setup --- Dockerfile | 2 +- ..._create_users_and_user_addresses_tables.py | 30 +++++++++ ..._create_users_and_user_addresses_tables.py | 67 +++++++++++++++++++ docker-compose.yml | 2 + 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/4ad2b65b1fb4_create_users_and_user_addresses_tables.py create mode 100644 alembic/versions/b26b69756757_create_users_and_user_addresses_tables.py diff --git a/Dockerfile b/Dockerfile index c1410224..9fda0358 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/alembic/versions/4ad2b65b1fb4_create_users_and_user_addresses_tables.py b/alembic/versions/4ad2b65b1fb4_create_users_and_user_addresses_tables.py new file mode 100644 index 00000000..5b091f2c --- /dev/null +++ b/alembic/versions/4ad2b65b1fb4_create_users_and_user_addresses_tables.py @@ -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 ### diff --git a/alembic/versions/b26b69756757_create_users_and_user_addresses_tables.py b/alembic/versions/b26b69756757_create_users_and_user_addresses_tables.py new file mode 100644 index 00000000..3985816f --- /dev/null +++ b/alembic/versions/b26b69756757_create_users_and_user_addresses_tables.py @@ -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 ### diff --git a/docker-compose.yml b/docker-compose.yml index 9c4431fc..1e7efad8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 From bb2867753459d978e4396b4b617c00045dc4c558 Mon Sep 17 00:00:00 2001 From: skarakash Date: Tue, 2 Sep 2025 11:59:46 +0300 Subject: [PATCH 2/4] authorize via swagger --- src/routes.py | 2 +- src/users/models/pydantic.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/routes.py b/src/routes.py index e893cd4b..0d9ffa36 100644 --- a/src/routes.py +++ b/src/routes.py @@ -4,5 +4,5 @@ class BaseRoutesPrefixes: openapi: str = '/openapi.json' catalogue: str = '/catalogue' - authentication: str = '/auth' + authentication: str = '' account: str = '/account' diff --git a/src/users/models/pydantic.py b/src/users/models/pydantic.py index cda96f2a..ad14bd45 100644 --- a/src/users/models/pydantic.py +++ b/src/users/models/pydantic.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Union, Optional from pydantic import ( BaseModel, @@ -11,8 +11,8 @@ 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 From 8464c1dbd2264e232b980f91e53a25181f49ce69 Mon Sep 17 00:00:00 2001 From: skarakash Date: Tue, 2 Sep 2025 13:07:09 +0300 Subject: [PATCH 3/4] added service, repository and pydantic model --- src/users/models/pydantic.py | 34 ++++++++++++++++++++++++++++++++++ src/users/repository.py | 12 +++++++++++- src/users/services.py | 17 +++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/users/models/pydantic.py b/src/users/models/pydantic.py index ad14bd45..f23c898c 100644 --- a/src/users/models/pydantic.py +++ b/src/users/models/pydantic.py @@ -19,3 +19,37 @@ class UserModel(BaseModel): 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: str + street: str + house: str + 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 diff --git a/src/users/repository.py b/src/users/repository.py index e53dab38..573bdde6 100644 --- a/src/users/repository.py +++ b/src/users/repository.py @@ -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]): @@ -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) \ No newline at end of file diff --git a/src/users/services.py b/src/users/services.py index ba7a2d37..6c167093 100644 --- a/src/users/services.py +++ b/src/users/services.py @@ -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, ) @@ -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_user_addressed(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) \ No newline at end of file From 026c42c88d8cc4aef3d5fbd402ac34873b9763e5 Mon Sep 17 00:00:00 2001 From: skarakash Date: Tue, 2 Sep 2025 16:03:16 +0300 Subject: [PATCH 4/4] added routes for addresses list and address details --- src/main.py | 8 +++++- src/users/models/pydantic.py | 6 ++-- src/users/routes.py | 1 + src/users/services.py | 2 +- src/users/views/__init__.py | 1 + src/users/views/user.py | 53 ++++++++++++++++++++++++++++++++++-- 6 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/main.py b/src/main.py index 2464299c..0a2b1126 100644 --- a/src/main.py +++ b/src/main.py @@ -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( @@ -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, diff --git a/src/users/models/pydantic.py b/src/users/models/pydantic.py index f23c898c..ffdaccf8 100644 --- a/src/users/models/pydantic.py +++ b/src/users/models/pydantic.py @@ -27,9 +27,9 @@ class UserAddressModel(BaseModel): id: Union[int, None] = None user_id: int title: Optional[str] = None - city: str - street: str - house: str + 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 diff --git a/src/users/routes.py b/src/users/routes.py index 1350e5c2..07f4ec96 100644 --- a/src/users/routes.py +++ b/src/users/routes.py @@ -3,6 +3,7 @@ class UserManagementRoutesPrefixes: user: str = '/user' + address: str = '/address' class UserRoutesPrefixes(BaseCrudPrefixes): diff --git a/src/users/services.py b/src/users/services.py index 6c167093..86d97d6c 100644 --- a/src/users/services.py +++ b/src/users/services.py @@ -39,7 +39,7 @@ class UserAddressService(BaseService[UserAddressModel]): def __init__(self, repository: UserAddressRepository): super().__init__(repository) - async def get_user_addressed(self, user_id: int): + 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): diff --git a/src/users/views/__init__.py b/src/users/views/__init__.py index 6cd7a856..cf3a5b18 100644 --- a/src/users/views/__init__.py +++ b/src/users/views/__init__.py @@ -1 +1,2 @@ from .user import router as user_router +from .user import address_router as user_address_router \ No newline at end of file diff --git a/src/users/views/user.py b/src/users/views/user.py index ef68fc64..4ee5c93b 100644 --- a/src/users/views/user.py +++ b/src/users/views/user.py @@ -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( @@ -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 \ No newline at end of file