From 2e33b1e363cc3311434f4f5dd401425a096d087c Mon Sep 17 00:00:00 2001 From: Mariia Zaiets Date: Tue, 23 Sep 2025 23:07:15 +0300 Subject: [PATCH] FastAPI Elastic and Redis --- Dockerfile | 2 +- alembic.ini | 2 +- docker-compose.yml | 3 - migrations/README | 2 +- migrations/env.py | 60 ++---- migrations/script.py.mako | 1 - migrations/versions/799909f2b79d_init.py | 135 ------------- migrations/versions/d3571e3efbb4_initial.py | 202 ++++++++++++++++++++ poetry.toml | 2 + src/catalogue/models/elasticsearch.py | 8 + src/catalogue/repository.py | 9 +- src/catalogue/routes.py | 6 +- src/catalogue/services.py | 29 ++- src/catalogue/utils.py | 24 ++- src/catalogue/views/__init__.py | 1 + src/catalogue/views/category.py | 63 ++++++ src/main.py | 7 +- 17 files changed, 360 insertions(+), 196 deletions(-) delete mode 100644 migrations/versions/799909f2b79d_init.py create mode 100644 migrations/versions/d3571e3efbb4_initial.py create mode 100644 poetry.toml create mode 100644 src/catalogue/views/category.py diff --git a/Dockerfile b/Dockerfile index be09a305..c691e80b 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.ini b/alembic.ini index 97904984..378925fe 100644 --- a/alembic.ini +++ b/alembic.ini @@ -58,7 +58,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne # are written from script.py.mako # output_encoding = utf-8 -#sqlalchemy.url = driver://user:pass@localhost/dbname +sqlalchemy.url = ${POSTGRES__URL} [post_write_hooks] diff --git a/docker-compose.yml b/docker-compose.yml index 33bb0269..c500cc29 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: web: build: . @@ -9,7 +7,6 @@ services: - "8000:8000" env_file: - ./ops/environment/.default.env - - ./ops/environment/.local.env depends_on: # db: # condition: service_healthy diff --git a/migrations/README b/migrations/README index e0d0858f..98e4f9c4 100644 --- a/migrations/README +++ b/migrations/README @@ -1 +1 @@ -Generic single-database configuration with an async dbapi. \ No newline at end of file +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py index c590effd..c4cc56a4 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -1,25 +1,15 @@ -import asyncio -import os from logging.config import fileConfig +from sqlalchemy import engine_from_config from sqlalchemy import pool -from sqlalchemy.engine import Connection -from sqlalchemy.ext.asyncio import async_engine_from_config -from sqlmodel import SQLModel from alembic import context -from src.catalogue.models.database import Product, ProductCategory, ProductDiscount, ProductImage, Category, StockRecord -from src.users.models.database import User, UserAddress -from src.company.models.database import Company - -postgres_url = os.getenv('POSTGRES__URL') - - # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config - +from sqlmodel import SQLModel +from src.catalogue.models.database import * # Interpret the config file for Python logging. # This line sets up loggers basically. if config.config_file_name is not None: @@ -29,13 +19,8 @@ # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata -target_metadata = SQLModel.metadata - -def include_object(object, name, type_, reflected, compare_to): - if type_ == "table": - return object.name in target_metadata.tables - return True +target_metadata = SQLModel.metadata # other values from the config, defined by the needs of env.py, # can be acquired: @@ -55,7 +40,7 @@ def run_migrations_offline() -> None: script output. """ - url = config.get_main_option("sqlalchemy.url", postgres_url) + url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, @@ -67,37 +52,26 @@ def run_migrations_offline() -> None: context.run_migrations() -def do_run_migrations(connection: Connection) -> None: - context.configure(connection=connection, target_metadata=target_metadata, include_object=include_object) - - with context.begin_transaction(): - context.run_migrations() - +def run_migrations_online() -> None: + """Run migrations in 'online' mode. -async def run_async_migrations() -> None: - """In this scenario we need to create an Engine + In this scenario we need to create an Engine and associate a connection with the context. - """ - - config_section = config.get_section(config.config_ini_section) - config_section['sqlalchemy.url'] = postgres_url - connectable = async_engine_from_config( - config_section, + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=pool.NullPool, ) - async with connectable.connect() as connection: - await connection.run_sync(do_run_migrations) - - await connectable.dispose() - - -def run_migrations_online() -> None: - """Run migrations in 'online' mode.""" + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) - asyncio.run(run_async_migrations()) + with context.begin_transaction(): + context.run_migrations() if context.is_offline_mode(): diff --git a/migrations/script.py.mako b/migrations/script.py.mako index 6ce33510..fbc4b07d 100644 --- a/migrations/script.py.mako +++ b/migrations/script.py.mako @@ -9,7 +9,6 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa -import sqlmodel ${imports if imports else ""} # revision identifiers, used by Alembic. diff --git a/migrations/versions/799909f2b79d_init.py b/migrations/versions/799909f2b79d_init.py deleted file mode 100644 index 99605ce0..00000000 --- a/migrations/versions/799909f2b79d_init.py +++ /dev/null @@ -1,135 +0,0 @@ -"""init - -Revision ID: 799909f2b79d -Revises: -Create Date: 2023-12-17 14:06:36.467099 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -import sqlmodel - - -# revision identifiers, used by Alembic. -revision: str = '799909f2b79d' -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('categories', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('image', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('parent_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['parent_id'], ['categories.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('company', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('hashed_password', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('is_admin', sa.Boolean(), nullable=False), - sa.Column('is_staff', sa.Boolean(), nullable=False), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('first_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('last_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('products', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('short_description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('users', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('hashed_password', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('is_admin', sa.Boolean(), nullable=False), - sa.Column('is_staff', sa.Boolean(), nullable=False), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('first_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('last_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('date_joined', sa.DateTime(), nullable=False), - sa.Column('last_login', sa.DateTime(), nullable=True), - sa.Column('is_temporary', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('product_categories', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('product_id', sa.Integer(), nullable=False), - sa.Column('category_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ), - sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('product_discounts', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('product_id', sa.Integer(), nullable=False), - sa.Column('discount_percent', sa.Integer(), nullable=True), - sa.Column('discount_amount', sa.Float(), nullable=True), - sa.Column('valid_from', sa.DateTime(), nullable=False), - sa.Column('valid_to', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('product_images', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('product_id', sa.Integer(), nullable=False), - sa.Column('original', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('thumbnail', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('caption', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('stock_records', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('product_id', sa.Integer(), nullable=False), - sa.Column('price', sa.Float(), nullable=False), - sa.Column('quantity', sa.Integer(), nullable=False), - sa.Column('date_created', sa.DateTime(), nullable=False), - sa.Column('additional_info', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('user_addresses', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('city', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('street', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('house', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('apartment', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('post_code', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('floor', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('additional_info', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('user_addresses') - op.drop_table('stock_records') - op.drop_table('product_images') - op.drop_table('product_discounts') - op.drop_table('product_categories') - op.drop_table('users') - op.drop_table('products') - op.drop_table('company') - op.drop_table('categories') - # ### end Alembic commands ### diff --git a/migrations/versions/d3571e3efbb4_initial.py b/migrations/versions/d3571e3efbb4_initial.py new file mode 100644 index 00000000..9fe49107 --- /dev/null +++ b/migrations/versions/d3571e3efbb4_initial.py @@ -0,0 +1,202 @@ +"""initial + +Revision ID: d3571e3efbb4 +Revises: +Create Date: 2025-09-23 17:43:48.503582 + +""" +from typing import Sequence, Union +import sqlmodel +from sqlalchemy import String +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'd3571e3efbb4' +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.drop_table('recommended_products') + op.drop_table('user_addresses') + op.drop_table('additional_products') + op.drop_table('users') + op.alter_column('categories', 'title', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('categories', 'description', + existing_type=sa.TEXT(), + type_=String(), + existing_nullable=True) + op.alter_column('categories', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=False) + op.drop_index(op.f('ix_categories_id'), table_name='categories') + op.alter_column('product_categories', 'product_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('product_categories', 'category_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_index(op.f('ix_product_categories_id'), table_name='product_categories') + op.alter_column('product_discounts', 'product_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('product_discounts', 'discount_amount', + existing_type=sa.NUMERIC(), + type_=sa.Float(), + existing_nullable=True) + op.alter_column('product_discounts', 'valid_from', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + op.alter_column('product_discounts', 'valid_to', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + op.drop_index(op.f('ix_product_discounts_id'), table_name='product_discounts') + op.alter_column('product_images', 'product_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_index(op.f('ix_product_images_id'), table_name='product_images') + op.alter_column('products', 'title', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('products', 'description', + existing_type=sa.TEXT(), + type_=String(), + existing_nullable=True) + op.execute("UPDATE products SET is_active = true WHERE is_active IS NULL") + op.alter_column('products', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=False) + op.drop_index(op.f('ix_products_id'), table_name='products') + op.alter_column('stock_records', 'product_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('stock_records', 'price', + existing_type=sa.NUMERIC(), + type_=sa.Float(), + nullable=False) + op.alter_column('stock_records', 'quantity', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('stock_records', 'date_created', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + op.drop_index(op.f('ix_stock_records_id'), table_name='stock_records') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_stock_records_id'), 'stock_records', ['id'], unique=False) + op.alter_column('stock_records', 'date_created', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + op.alter_column('stock_records', 'quantity', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('stock_records', 'price', + existing_type=sa.Float(), + type_=sa.NUMERIC(), + nullable=True) + op.alter_column('stock_records', 'product_id', + existing_type=sa.INTEGER(), + nullable=True) + op.create_index(op.f('ix_products_id'), 'products', ['id'], unique=False) + op.alter_column('products', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=True) + op.alter_column('products', 'description', + existing_type=sqlmodel.sql.sqltypes.AutoString(), + type_=sa.TEXT(), + existing_nullable=True) + op.alter_column('products', 'title', + existing_type=sa.VARCHAR(), + nullable=True) + op.create_index(op.f('ix_product_images_id'), 'product_images', ['id'], unique=False) + op.alter_column('product_images', 'product_id', + existing_type=sa.INTEGER(), + nullable=True) + op.create_index(op.f('ix_product_discounts_id'), 'product_discounts', ['id'], unique=False) + op.alter_column('product_discounts', 'valid_to', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + op.alter_column('product_discounts', 'valid_from', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + op.alter_column('product_discounts', 'discount_amount', + existing_type=sa.Float(), + type_=sa.NUMERIC(), + existing_nullable=True) + op.alter_column('product_discounts', 'product_id', + existing_type=sa.INTEGER(), + nullable=True) + op.create_index(op.f('ix_product_categories_id'), 'product_categories', ['id'], unique=False) + op.alter_column('product_categories', 'category_id', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('product_categories', 'product_id', + existing_type=sa.INTEGER(), + nullable=True) + op.create_index(op.f('ix_categories_id'), 'categories', ['id'], unique=False) + op.alter_column('categories', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=True) + op.alter_column('categories', 'description', + existing_type=sqlmodel.sql.sqltypes.AutoString(), + type_=sa.TEXT(), + existing_nullable=True) + op.alter_column('categories', 'title', + existing_type=sa.VARCHAR(), + nullable=True) + op.create_table('users', + sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('users_id_seq'::regclass)"), autoincrement=True, nullable=False), + sa.Column('email', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('phone_number', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('hashed_password', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('is_admin', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column('is_staff', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column('first_name', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('last_name', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('date_joined', postgresql.TIMESTAMP(), autoincrement=False, nullable=False), + sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column('is_temporary', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name='users_pkey'), + postgresql_ignore_search_path=False + ) + op.create_table('additional_products', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('primary_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('additional_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['additional_id'], ['products.id'], name=op.f('additional_products_additional_id_fkey')), + sa.ForeignKeyConstraint(['primary_id'], ['products.id'], name=op.f('additional_products_primary_id_fkey')), + sa.PrimaryKeyConstraint('id', name=op.f('additional_products_pkey')) + ) + op.create_table('user_addresses', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('title', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('city', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('street', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('house', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('apartment', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('post_code', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('floor', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('additional_info', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('user_addresses_user_id_fkey')), + sa.PrimaryKeyConstraint('id', name=op.f('user_addresses_pkey')) + ) + op.create_table('recommended_products', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('primary_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('recommended_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['primary_id'], ['products.id'], name=op.f('recommended_products_primary_id_fkey')), + sa.ForeignKeyConstraint(['recommended_id'], ['products.id'], name=op.f('recommended_products_recommended_id_fkey')), + sa.PrimaryKeyConstraint('id', name=op.f('recommended_products_pkey')) + ) + # ### end Alembic commands ### diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 00000000..ab1033bd --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true diff --git a/src/catalogue/models/elasticsearch.py b/src/catalogue/models/elasticsearch.py index 06b50df1..c3e61114 100644 --- a/src/catalogue/models/elasticsearch.py +++ b/src/catalogue/models/elasticsearch.py @@ -5,6 +5,7 @@ PRODUCT_INDEX = 'products_index' +CATEGORY_INDEX = 'categories_index' class ProductIndex(Document): @@ -14,3 +15,10 @@ class ProductIndex(Document): class Index: name = PRODUCT_INDEX + +class CategoryIndex(Document): + title = Text() + description = Text() + + class Index: + name = CATEGORY_INDEX \ No newline at end of file diff --git a/src/catalogue/repository.py b/src/catalogue/repository.py index fab3dcd5..fbd0fcfc 100644 --- a/src/catalogue/repository.py +++ b/src/catalogue/repository.py @@ -1,7 +1,7 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession -from src.catalogue.models.database import Product +from src.catalogue.models.database import Product, Category from src.common.databases.postgres import get_session from src.common.repository.sqlalchemy import BaseSQLAlchemyRepository @@ -13,3 +13,10 @@ def __init__(self, session: AsyncSession): def get_product_repository(session: AsyncSession = Depends(get_session)) -> ProductRepository: return ProductRepository(session=session) + +class CategoryRepository(BaseSQLAlchemyRepository[Category]): + def __init__(self, session: AsyncSession): + super().__init__(model=Category, 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 f90d244b..165e98a1 100644 --- a/src/catalogue/routes.py +++ b/src/catalogue/routes.py @@ -3,8 +3,12 @@ class CatalogueRoutesPrefixes: product: str = '/product' - + catalogue: str = '/catalogue' class ProductRoutesPrefixes(BaseCrudPrefixes): search: str = '/search' update_index: str = '/update-index' + +class CategoryRoutesPrefixes(BaseCrudPrefixes): + search: str = '/search' + update_index = '/update-index' \ No newline at end of file diff --git a/src/catalogue/services.py b/src/catalogue/services.py index 196dea7c..6e685e99 100644 --- a/src/catalogue/services.py +++ b/src/catalogue/services.py @@ -4,12 +4,12 @@ from fastapi import Depends from src.base_settings import base_settings -from src.catalogue.models.database import Product +from src.catalogue.models.database import Product, Category from src.catalogue.repository import ( - ProductRepository, - get_product_repository, + ProductRepository, CategoryRepository, + get_product_repository, get_category_repository, ) -from src.catalogue.utils import ProductElasticManager +from src.catalogue.utils import ProductElasticManager, CategoryElasticManager from src.common.enums import TaskStatus from src.common.service import BaseService from src.general.schemas.task_status import TaskStatusModel @@ -45,3 +45,24 @@ async def update_search_index(self, uuid): def get_product_service(repo: ProductRepository = Depends(get_product_repository)) -> ProductService: return ProductService(repository=repo) + +class CategoryService(BaseService[Category]): + def __init__(self, repository: CategoryRepository): + super().__init__(repository) + + async def update_category_index(self, uuid): + category = await self.list() + + try: + await CategoryElasticManager().update_index(category=category) + except ConnectionError as exc: + await TaskStatusModel(uuid=uuid, status=TaskStatus.ERROR, details=str(exc)).save_to_redis() + + await TaskStatusModel( + uuid=uuid, + status=TaskStatus.DONE, + done_at=datetime.utcnow().strftime(base_settings.date_time_format), + ).save_to_redis() + +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/utils.py b/src/catalogue/utils.py index 13560a95..43b76761 100644 --- a/src/catalogue/utils.py +++ b/src/catalogue/utils.py @@ -7,10 +7,10 @@ ) from fastapi import Depends -from src.catalogue.models.database import Product +from src.catalogue.models.database import Product, Category from src.catalogue.models.elasticsearch import ( - PRODUCT_INDEX, - ProductIndex, + PRODUCT_INDEX, CATEGORY_INDEX, + ProductIndex, CategoryIndex ) from src.catalogue.models.pydantic import ProductElasticResponse from src.common.databases.elasticsearch import elastic_client @@ -28,7 +28,7 @@ async def init_indices(self): products_index.document(ProductIndex) - if not await products_index.exists(): + if not products_index.exists(): await products_index.create() @staticmethod @@ -79,3 +79,19 @@ async def update_index(self, products: list[Product]) -> None: if bulk_data: await self.client.bulk(body=bulk_data) + +class CategoryElasticManager: + def __init__(self, client: Annotated[AsyncElasticsearch, Depends(elastic_client)] = elastic_client): + self.client = client + + async def init_indices(self): + categories_index = Index( + name=CATEGORY_INDEX, + using=self.client, + ) + + categories_index.document(CategoryIndex) + + exists = await self.client.indices.exists(index=CATEGORY_INDEX) + if not exists: + await self.client.indices.create(index=CATEGORY_INDEX) \ No newline at end of file diff --git a/src/catalogue/views/__init__.py b/src/catalogue/views/__init__.py index ab8e1772..ffa22120 100644 --- a/src/catalogue/views/__init__.py +++ b/src/catalogue/views/__init__.py @@ -1 +1,2 @@ from .product import router as product_router +from .category import router as category_router diff --git a/src/catalogue/views/category.py b/src/catalogue/views/category.py new file mode 100644 index 00000000..17a68b6b --- /dev/null +++ b/src/catalogue/views/category.py @@ -0,0 +1,63 @@ +from typing import ( + Annotated, + Union, +) + +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + Response, + status, +) + +from src.catalogue.models.database import Category +from src.catalogue.routes import ( + CategoryRoutesPrefixes, CatalogueRoutesPrefixes +) +from src.catalogue.services import get_category_service +from src.common.enums import TaskStatus +from src.common.exceptions.base import ObjectDoesNotExistException +from src.common.schemas.common import ErrorResponse +from src.general.schemas.task_status import TaskStatusModel + + +router = APIRouter(prefix=CatalogueRoutesPrefixes.catalogue) + +@router.get( + CategoryRoutesPrefixes.search, + status_code=status.HTTP_200_OK, +) +async def search( + keyword: str, + service: Annotated[get_category_service, Depends()], +): + """ + Search categories in Elasticsearch by keyword. + + Returns: + List of categories matching the search. + """ + response = await service.search(keyword=keyword) + + return response + +@router.post( + CategoryRoutesPrefixes.update_index, + status_code=status.HTTP_200_OK, +) +async def update_elastic( + background_tasks: BackgroundTasks, + service: Annotated[get_category_service, Depends()], +): + """ + Update categories index in Elasticsearch in background. + + Returns: + Task status. + """ + status_model = await TaskStatusModel(status=TaskStatus.IN_PROGRESS).save_to_redis() + + background_tasks.add_task(service.update_category_index, status_model.uuid) + + return await TaskStatusModel().get_from_redis(uuid=status_model.uuid) \ No newline at end of file diff --git a/src/main.py b/src/main.py index 8a6f0a9b..2e7b9af9 100644 --- a/src/main.py +++ b/src/main.py @@ -7,7 +7,7 @@ from src.authentication.views import router as auth_router from src.base_settings import base_settings from src.catalogue.utils import ProductElasticManager -from src.catalogue.views import product_router +from src.catalogue.views import product_router, category_router from src.common.databases.postgres import ( engine, init_db, @@ -44,6 +44,11 @@ def include_routes(application: FastAPI) -> None: prefix=BaseRoutesPrefixes.account, tags=['Account'], ) + application.include_router( + router=category_router, + prefix=BaseRoutesPrefixes.catalogue, + tags=['Categories'], + ) def get_application() -> FastAPI: