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 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/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..ffdaccf8 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,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 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/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 ba7a2d37..86d97d6c 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_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) \ No newline at end of file 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