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/cedcfebfb6ad_add_product_tables.py b/alembic/versions/256b8bcd290c_init_schema.py similarity index 94% rename from alembic/versions/cedcfebfb6ad_add_product_tables.py rename to alembic/versions/256b8bcd290c_init_schema.py index 0833e7b0..cbc4128f 100644 --- a/alembic/versions/cedcfebfb6ad_add_product_tables.py +++ b/alembic/versions/256b8bcd290c_init_schema.py @@ -1,22 +1,19 @@ -"""Add product tables +"""init schema -Revision ID: cedcfebfb6ad -Revises: 40f8af84ecb8 -Create Date: 2023-12-06 22:33:42.993363 +Revision ID: 256b8bcd290c +Revises: +Create Date: 2025-08-25 09:37:06.747222 """ -from typing import ( - Sequence, - Union, -) +from typing import Sequence, Union +from alembic import op import sqlalchemy as sa -from alembic import op # revision identifiers, used by Alembic. -revision: str = 'cedcfebfb6ad' -down_revision: Union[str, None] = '40f8af84ecb8' +revision: str = '256b8bcd290c' +down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/alembic/versions/3b3e7aef66e3_.py b/alembic/versions/3b3e7aef66e3_.py deleted file mode 100644 index 4ec9d76b..00000000 --- a/alembic/versions/3b3e7aef66e3_.py +++ /dev/null @@ -1,53 +0,0 @@ -"""empty message - -Revision ID: 3b3e7aef66e3 -Revises: -Create Date: 2023-11-30 16:41:05.782890 - -""" -from typing import ( - Sequence, - Union, -) - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = '3b3e7aef66e3' -down_revision: Union[str, None] = None -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('username', sa.String(), nullable=True), - 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.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_index(op.f('ix_users_username'), 'users', ['username'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_users_username'), table_name='users') - 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/alembic/versions/40f8af84ecb8_.py b/alembic/versions/40f8af84ecb8_.py deleted file mode 100644 index 44326f97..00000000 --- a/alembic/versions/40f8af84ecb8_.py +++ /dev/null @@ -1,58 +0,0 @@ -"""empty message - -Revision ID: 40f8af84ecb8 -Revises: 3b3e7aef66e3 -Create Date: 2023-12-03 13:16:03.296231 - -""" -from typing import ( - Sequence, - Union, -) - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = '40f8af84ecb8' -down_revision: Union[str, None] = '3b3e7aef66e3' -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('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) - op.add_column('users', sa.Column('date_joined', sa.DateTime(), nullable=True)) - op.add_column('users', sa.Column('last_login', sa.DateTime(), nullable=True)) - op.add_column('users', sa.Column('is_temporary', sa.Boolean(), nullable=True)) - op.drop_index('ix_users_username', table_name='users') - op.drop_column('users', 'username') - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('users', sa.Column('username', sa.VARCHAR(), autoincrement=False, nullable=True)) - op.create_index('ix_users_username', 'users', ['username'], unique=False) - op.drop_column('users', 'is_temporary') - op.drop_column('users', 'last_login') - op.drop_column('users', 'date_joined') - op.drop_index(op.f('ix_user_addresses_id'), table_name='user_addresses') - op.drop_table('user_addresses') - # ### end Alembic commands ### diff --git a/reset_db.sh b/reset_db.sh new file mode 100755 index 00000000..2b8ad791 --- /dev/null +++ b/reset_db.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +echo "Dropping and recreating public schema..." +docker compose exec -T db psql -U user -d fastapi_shop -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" + +echo "Stamping Alembic to head..." +docker compose exec -T web alembic stamp head + +echo "Running migrations..." +docker compose exec -T web alembic upgrade head + +echo "✅ Database reset complete!" \ No newline at end of file diff --git a/src/catalogue/models/pydantic.py b/src/catalogue/models/pydantic.py index 320c2cb1..5b4f6585 100644 --- a/src/catalogue/models/pydantic.py +++ b/src/catalogue/models/pydantic.py @@ -15,3 +15,11 @@ class ProductModel(BaseModel): class Config: from_attributes = True + +class CategoryModel(BaseModel): + id: Optional[int] + title: str + description: Optional[str] + image: Optional[str] + is_active: bool + parent_id: Optional[int] \ No newline at end of file diff --git a/src/catalogue/repository.py b/src/catalogue/repository.py index c5c77cc8..29d3feca 100644 --- a/src/catalogue/repository.py +++ b/src/catalogue/repository.py @@ -1,8 +1,8 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession -from src.catalogue.models.pydantic import ProductModel -from src.catalogue.models.sqlalchemy import Product +from src.catalogue.models.pydantic import ProductModel, CategoryModel +from src.catalogue.models.sqlalchemy import Product, Category from src.common.databases.postgres import ( get_session, ) @@ -16,3 +16,11 @@ def __init__(self, session: AsyncSession): def get_product_repository(session: AsyncSession = Depends(get_session)) -> ProductRepository: return ProductRepository(session=session) + +class CategoryRepository(BaseSQLAlchemyRepository[Category, CategoryModel]): + def __init__(self, session: AsyncSession): + super().__init__(model=Category, pydantic_model=CategoryModel, session=session) + + +def get_category_repository(session: AsyncSession = Depends(get_session)) -> CategoryRepository: + return CategoryRepository(session=session) \ No newline at end of file diff --git a/src/catalogue/routes.py b/src/catalogue/routes.py index c40fe3ac..372b3cf5 100644 --- a/src/catalogue/routes.py +++ b/src/catalogue/routes.py @@ -3,7 +3,11 @@ class CatalogueRoutesPrefixes: product: str = '/product' + category: str = '/category' class ProductRoutesPrefixes(BaseCrudPrefixes): ... + +class CategoryRoutesPrefixes(BaseCrudPrefixes): + ... \ No newline at end of file diff --git a/src/catalogue/services.py b/src/catalogue/services.py index 421359b9..3666a78f 100644 --- a/src/catalogue/services.py +++ b/src/catalogue/services.py @@ -1,9 +1,11 @@ from fastapi import Depends -from src.catalogue.models.pydantic import ProductModel +from src.catalogue.models.pydantic import ProductModel, CategoryModel from src.catalogue.repository import ( ProductRepository, get_product_repository, + CategoryRepository, + get_category_repository, ) from src.common.service import BaseService @@ -15,3 +17,12 @@ def __init__(self, repository: ProductRepository): def get_product_service(repo: ProductRepository = Depends(get_product_repository)) -> ProductService: return ProductService(repository=repo) + + +class CategoryService(BaseService[CategoryModel]): + def __init__(self, repository: CategoryRepository): + super().__init__(repository) + + +def get_category_service(repo: CategoryRepository = Depends(get_category_repository)) -> CategoryService: + return CategoryService(repository=repo) \ No newline at end of file diff --git a/src/catalogue/views.py b/src/catalogue/views.py index 636b0841..841686e5 100644 --- a/src/catalogue/views.py +++ b/src/catalogue/views.py @@ -10,19 +10,21 @@ status, ) -from src.catalogue.models.pydantic import ProductModel +from src.catalogue.models.pydantic import ProductModel, CategoryModel from src.catalogue.routes import ( CatalogueRoutesPrefixes, ProductRoutesPrefixes, + CategoryRoutesPrefixes ) from src.catalogue.services import ( - get_product_service, + get_product_service, get_category_service, ) from src.common.exceptions.base import ObjectDoesNotExistException from src.common.schemas.common import ErrorResponse product_router = APIRouter(prefix=CatalogueRoutesPrefixes.product) +category_router = APIRouter(prefix=CatalogueRoutesPrefixes.category) @product_router.get( @@ -69,3 +71,41 @@ async def product_detail( return ErrorResponse(message=exc.message) return response + +@category_router.get( + CategoryRoutesPrefixes.root, + status_code=status.HTTP_200_OK, + response_model=list[CategoryModel], +) +async def category_list(category_service=Depends(get_category_service)) -> list[CategoryModel]: + + """ + Get list of categories.. + + Returns: + Response with list of categories... + """ + return await category_service.list() + +@category_router.get( + CategoryRoutesPrefixes.detail, + responses={ + status.HTTP_200_OK: {'model': CategoryModel}, + status.HTTP_404_NOT_FOUND: {'model': ErrorResponse}, + }, + status_code=status.HTTP_200_OK, + response_model=Union[CategoryModel, ErrorResponse], +) +async def category_detail( + response: Response, + pk: int, + service: Annotated[get_category_service, Depends()], +) -> Union[Response, ErrorResponse]: + try: + response = await service.detail(pk=pk) + except ObjectDoesNotExistException as exc: + response.status_code = status.HTTP_404_NOT_FOUND + return ErrorResponse(message=exc.message) + + return response + diff --git a/src/common/repository/sqlalchemy.py b/src/common/repository/sqlalchemy.py index 863684d8..35952db2 100644 --- a/src/common/repository/sqlalchemy.py +++ b/src/common/repository/sqlalchemy.py @@ -32,14 +32,14 @@ async def get(self, pk: int) -> PType: instance = result.scalar_one_or_none() if not instance: raise ObjectDoesNotExistException() - return self.pydantic_model.from_orm(instance) + return self.pydantic_model.model_validate(instance, from_attributes=True) async def create(self, instance_data: PType) -> PType: instance = self.model(**instance_data.model_dump()) self.session.add(instance) await self.session.commit() await self.session.refresh(instance) - return self.pydantic_model.from_orm(instance) + return self.pydantic_model.model_validate(instance, from_attributes=True) async def update(self, pk: int, update_data: PType) -> PType: await self.session.execute( @@ -56,7 +56,7 @@ async def all(self) -> List[PType]: stmt = select(self.model) result = await self.session.execute(stmt) instances = result.scalars().all() - return [self.pydantic_model.model_validate(instance) for instance in instances] + return [self.pydantic_model.model_validate(instance, from_attributes=True) for instance in instances] async def filter(self, **kwargs) -> List[PType]: stmt = select(self.model).filter_by(**kwargs) diff --git a/src/main.py b/src/main.py index 3dad5947..bd461c2a 100644 --- a/src/main.py +++ b/src/main.py @@ -3,7 +3,7 @@ from src.admin import register_admin_views from src.base_settings import base_settings -from src.catalogue.views import product_router +from src.catalogue.views import product_router, category_router from src.common.databases.postgres import postgres from src.general.views import router as status_router from src.routes import BaseRoutesPrefixes @@ -19,6 +19,12 @@ def include_routes(application: FastAPI) -> None: tags=['Catalogue'], ) + application.include_router( + router=category_router, + prefix=BaseRoutesPrefixes.catalogue, + tags=['Catalogue'], + ) + def get_application() -> FastAPI: application = FastAPI(