diff --git a/.gitignore b/.gitignore index ea66313..888aa7c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ .env .coverage +docker-compose.override.yml +/notebooks +/.run diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..faaa566 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,89 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = tom_calculator/alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/config-test.yml b/config-test.yml new file mode 100644 index 0000000..e69de29 diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..91601ce --- /dev/null +++ b/config.yml @@ -0,0 +1,3 @@ +db: + dsn: postgresql+psycopg2://tom:jae2Ahfe@postgres:5432/tomdb + async_dsn: postgresql+asyncpg://tom:jae2Ahfe@postgres:5432/tomdb diff --git a/data/discounts.csv b/data/discounts.csv new file mode 100644 index 0000000..e5a4405 --- /dev/null +++ b/data/discounts.csv @@ -0,0 +1,6 @@ +amount,rate +1000,3 +5000,5 +7000,7 +10000,10 +50000,15 diff --git a/data/taxes.csv b/data/taxes.csv new file mode 100644 index 0000000..d3c5b98 --- /dev/null +++ b/data/taxes.csv @@ -0,0 +1,6 @@ +state_name,rate +UT,6.85 +NV,8 +TX,6.25 +AL,4 +CA,8.25 diff --git a/docker-compose.yml b/docker-compose.yml index bb0fbf2..1e4c89b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,27 +1,16 @@ version: "3.7" -# logging option -x-logging: - &logging - driver: "syslog" -# driver: "json-file" - services: app: image: tom-calculator/app:latest build: context: . dockerfile: ./docker/web/Dockerfile - volumes: - - .:/app - working_dir: /app - env_file: - - .env - command: python run - logging: - << : *logging - options: - tag: tom-calculator__app + # TODO: move to Dockerfile + environment: + TOM_CONFIG: "/app/config.yml" + TOM_DATA: "/app/data" + command: python tom_calculator/main.py restart: always ports: - "18000:80" @@ -31,16 +20,10 @@ services: build: context: ./docker/postgres dockerfile: Dockerfile - environment: - POSTGRES_USER: tom - POSTGRES_PASSWORD: password - POSTGRES_DB: tom + env_file: + - .env volumes: - tom-calculator__postgres:/var/lib/postgresql/data - logging: - << : *logging - options: - tag: tom-calculator__postgres restart: always ports: - "18001:5432" diff --git a/docker/postgres/Dockerfile b/docker/postgres/Dockerfile index 5c36687..10a42b3 100644 --- a/docker/postgres/Dockerfile +++ b/docker/postgres/Dockerfile @@ -1,2 +1,10 @@ FROM postgres:13.1 -COPY test.sql /docker-entrypoint-initdb.d +COPY initdb.d /docker-entrypoint-initdb.d + +RUN echo "ru_RU.UTF-8 UTF-8" > /etc/locale.gen && \ + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && \ + locale-gen + +ENV LANG=en_US.UTF-8 \ + LANGUAGE=en_US:en \ + LC_ALL=en_US.UTF-8 diff --git a/docker/postgres/initdb.d/01_extensions.sh b/docker/postgres/initdb.d/01_extensions.sh new file mode 100644 index 0000000..885240d --- /dev/null +++ b/docker/postgres/initdb.d/01_extensions.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +psql --dbname template1 --username "postgres" <<-EOSQL +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +EOSQL diff --git a/docker/postgres/initdb.d/02_create.sh b/docker/postgres/initdb.d/02_create.sh new file mode 100644 index 0000000..af698df --- /dev/null +++ b/docker/postgres/initdb.d/02_create.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +psql --dbname postgres --username "postgres" <<-EOSQL +CREATE USER ${TOM_USER} WITH PASSWORD '${TOM_USER_PASSWORD}'; +CREATE USER ${TOM_USER_TEST} WITH PASSWORD '${TOM_USER_TEST_PASSWORD}'; +CREATE DATABASE ${TOM_DBNAME} WITH OWNER = ${TOM_USER}; +CREATE DATABASE ${TOM_DBNAME_TEST} WITH OWNER = ${TOM_USER_TEST}; +EOSQL diff --git a/docker/postgres/test.sql b/docker/postgres/test.sql deleted file mode 100644 index 2ccc3f5..0000000 --- a/docker/postgres/test.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE DATABASE test WITH OWNER = tom; diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile index 7e74efe..7e8205e 100644 --- a/docker/web/Dockerfile +++ b/docker/web/Dockerfile @@ -11,12 +11,13 @@ RUN apt-get update \ locales \ # python setup && pip install --upgrade pip \ - && pip install --no-cache-dir -e /app \ + && pip install --no-cache-dir -e /app/[dev] \ # clean && rm -rf /var/lib/apt/lists/* \ && apt-get -qq --allow-remove-essential remove gcc \ && apt-get -qq autoremove \ && apt-get clean \ + && echo "ru_RU.UTF-8 UTF-8" > /etc/locale.gen \ && echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen \ && locale-gen diff --git a/entrypoint.sh b/entrypoint.sh index 753074d..ef62f4b 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash -pip install -e . +pip install -e .[dev] exec "$@"; diff --git a/setup.cfg b/setup.cfg index 7be1d3d..5d1aca6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,17 @@ [flake8] max-line-length = 120 +exclude = + notebooks + tom_calculator/alembic [mypy] ignore_missing_imports = True +plugins = sqlalchemy.ext.mypy.plugin + +[coverage:run] +source = tom_calculator +omit = + tom_calculator/alembic/* + +[coverage:report] +fail_under = 75 diff --git a/setup.py b/setup.py index 83fa98b..ba7f012 100644 --- a/setup.py +++ b/setup.py @@ -7,16 +7,32 @@ ) install_requires = [ + 'alembic==1.6.5', + 'async_exit_stack==1.0.1', + 'async_generator==1.10', + 'asyncpg==0.24.0', + 'dependency-injector==4.35.2', + 'fastapi==0.68.0', 'psycopg2==2.9.1', - 'requests==2.25.1', + 'sqlalchemy==1.4.22', + 'typer==0.3.2', + 'uvicorn[standard]==0.13.4', + 'uvloop==0.16.0', ] -dev_require = [ +test_require = [ 'flake8==3.9.2', 'mypy==0.910', + 'pytest-asyncio==0.15.1', 'pytest-cov==2.12.1', 'pytest-sugar==0.9.4', 'pytest==6.2.4', + 'sqlalchemy[mypy]==1.4.22', +] + +dev_require = [ + 'pydevd-pycharm~=211.7142.13', + 'jupyter==1.0.0', ] setup( @@ -29,5 +45,14 @@ packages=find_packages(include=['tom_calculator']), python_requires='>=3.9', install_requires=install_requires, - extras_require={'dev': dev_require}, + extras_require={ + 'test': test_require, + 'dev': dev_require, + }, + entry_points={ + 'console_scripts': [ + 'tom-calculator = tom_calculator.cli:app', + ], + }, + ) diff --git a/tom_calculator/__init__.py b/tom_calculator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tom_calculator/alembic/README b/tom_calculator/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/tom_calculator/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/tom_calculator/alembic/env.py b/tom_calculator/alembic/env.py new file mode 100644 index 0000000..75e6197 --- /dev/null +++ b/tom_calculator/alembic/env.py @@ -0,0 +1,80 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from tom_calculator.application import app +from tom_calculator.database import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", app.container.config.db.dsn()) +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/tom_calculator/alembic/script.py.mako b/tom_calculator/alembic/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/tom_calculator/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/tom_calculator/alembic/versions/a165aa988b0b_added_tables.py b/tom_calculator/alembic/versions/a165aa988b0b_added_tables.py new file mode 100644 index 0000000..3852250 --- /dev/null +++ b/tom_calculator/alembic/versions/a165aa988b0b_added_tables.py @@ -0,0 +1,59 @@ +"""Added tables. + +Revision ID: a165aa988b0b +Revises: +Create Date: 2021-08-16 11:28:56.743628 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'a165aa988b0b' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('discounts', + sa.Column('id', sa.Integer(), nullable=False, comment='Dicsount identifier.'), + sa.Column('amount', postgresql.MONEY(), nullable=False, comment='Amount for which this discount is applicable.'), + sa.Column('rate', sa.Numeric(precision=16, scale=0), nullable=False, comment='Discount rate applied to amount measured in `%`.'), + sa.PrimaryKeyConstraint('id'), + comment='Table with discount rates applied to the amount.' + ) + op.create_index(op.f('ix_discounts_amount'), 'discounts', ['amount'], unique=False) + op.create_table('orders', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False, comment='Order identifier.'), + sa.Column('ts', sa.DateTime(), server_default=sa.text('now()'), nullable=False, comment='Order timestamp.'), + sa.Column('amount', postgresql.MONEY(), nullable=False, comment='Amount of order received from calculator.'), + sa.Column('after_discount', postgresql.MONEY(), nullable=False, comment='Amount of order after discount has been applied.'), + sa.Column('tax', postgresql.MONEY(), nullable=False, comment='Tax sum calculated from discounted amount.'), + sa.Column('total', postgresql.MONEY(), nullable=False, comment='Total amount of order after discount and tax.'), + sa.PrimaryKeyConstraint('id'), + comment='Table with orders.' + ) + op.create_index(op.f('ix_orders_ts'), 'orders', ['ts'], unique=False) + op.create_table('taxes', + sa.Column('id', sa.Integer(), nullable=False, comment='Tax identifier.'), + sa.Column('state_name', sa.String(length=20), nullable=False, comment='Name of the state.'), + sa.Column('rate', sa.Numeric(precision=16, scale=2), nullable=False, comment='Rate for the current state measured in `%`.'), + sa.PrimaryKeyConstraint('id'), + comment='Table with tax rates per state.' + ) + op.create_index(op.f('ix_taxes_state_name'), 'taxes', ['state_name'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_taxes_state_name'), table_name='taxes') + op.drop_table('taxes') + op.drop_index(op.f('ix_orders_ts'), table_name='orders') + op.drop_table('orders') + op.drop_index(op.f('ix_discounts_amount'), table_name='discounts') + op.drop_table('discounts') + # ### end Alembic commands ### diff --git a/tom_calculator/application.py b/tom_calculator/application.py new file mode 100644 index 0000000..755d5a8 --- /dev/null +++ b/tom_calculator/application.py @@ -0,0 +1,27 @@ +import logging + +from fastapi import FastAPI + +import tom_calculator +from tom_calculator import endpoints +from tom_calculator.containers import Container +from tom_calculator.util import get_config_path + +logger = logging.getLogger(__name__) + + +def create_app() -> FastAPI: + """Application factory.""" + config_path = get_config_path() + container = Container() + container.config.from_yaml(config_path) + container.wire(packages=[tom_calculator]) + + app = FastAPI() + app.container = container + app.include_router(endpoints.router) + + return app + + +app = create_app() diff --git a/tom_calculator/cli.py b/tom_calculator/cli.py new file mode 100644 index 0000000..ce483ac --- /dev/null +++ b/tom_calculator/cli.py @@ -0,0 +1,45 @@ +import asyncio +import os +import subprocess + +from dependency_injector.wiring import inject, Provide +import typer + +from tom_calculator import services +from tom_calculator.application import create_app + +app = typer.Typer() + + +@inject +def load( + datadir: str, + loader_service: services.LoaderService = Provide['loader_service'], +): + asyncio.run(loader_service.load(datadir)) + + +@app.callback() +def main( + ctx: typer.Context, +): + main_app = create_app() + ctx.obj = main_app + + +@app.command() +def migrate(): + typer.echo('Starting migration...') + subprocess.run(['alembic', 'migrate', 'head']) + + +@app.command() +def migrate_data( +): + typer.echo('Migrating data...') + datadir = os.getenv('TOM_DATA') + load(datadir) + + +if __name__ == '__main__': + app() diff --git a/tom_calculator/containers.py b/tom_calculator/containers.py new file mode 100644 index 0000000..f099227 --- /dev/null +++ b/tom_calculator/containers.py @@ -0,0 +1,30 @@ +from dependency_injector import containers, providers + +from tom_calculator.database import Database +from tom_calculator import services + + +class Container(containers.DeclarativeContainer): + + config = providers.Configuration() + + db = providers.Singleton(Database, db_dsn=config.db.async_dsn) + + discount_service = providers.Factory( + services.DiscountService, + session=db.provided.session, + ) + + tax_service = providers.Factory( + services.TaxService, + session=db.provided.session, + ) + + order_service = providers.Factory( + services.OrderService, + session=db.provided.session, + ) + + loader_service = providers.Factory( + services.LoaderService, + ) diff --git a/tom_calculator/database.py b/tom_calculator/database.py new file mode 100644 index 0000000..dfa1c97 --- /dev/null +++ b/tom_calculator/database.py @@ -0,0 +1,45 @@ +import logging +from asyncio import current_task +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from typing import Callable + +from sqlalchemy import orm +from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session, create_async_engine + +logger = logging.getLogger(__name__) + +Base = orm.declarative_base() + +TSession = Callable[..., AbstractAsyncContextManager[orm.Session]] + +class Database: + """Database.""" + def __init__(self, db_dsn: str) -> None: + self._engine = create_async_engine(db_dsn, future=True, echo=True) + self._async_session_factory = orm.sessionmaker( + class_=AsyncSession, + autocommit=False, + autoflush=False, + bind=self._engine, + ) + self._session = async_scoped_session(self._async_session_factory, scopefunc=current_task) + + async def create_database(self) -> None: + async with self._engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + async def close(self) -> None: + await self._engine.dispose() + + @asynccontextmanager + async def session(self) -> TSession: + async with self._session() as session: + try: + yield session + except Exception: + logger.exception('Session rollback because of exception') + await session.rollback() + raise + finally: + await session.close() diff --git a/tom_calculator/endpoints.py b/tom_calculator/endpoints.py new file mode 100644 index 0000000..0bbffe3 --- /dev/null +++ b/tom_calculator/endpoints.py @@ -0,0 +1,40 @@ +from uuid import UUID +from dependency_injector.wiring import Provide, inject +from fastapi import APIRouter, Depends, status +from starlette.responses import RedirectResponse + +from tom_calculator.containers import Container +from tom_calculator import schemas, services + +router = APIRouter() + + +@router.get('/') +async def root(): + response = RedirectResponse(url='/docs#/default/order_create_order_post') + return response + + +@router.post( + '/order', + status_code=status.HTTP_201_CREATED, + response_model=schemas.OrderItemOut, +) +@inject +async def order_create( + item: schemas.CalculatorIn, + order_service: services.OrderService = Depends(Provide[Container.order_service]), +): + return await order_service.create(item.dict()) + + +@router.get( + '/order/{item_id}', + response_model=schemas.OrderItemOut, +) +@inject +async def order_get( + item_id: UUID, + order_service: services.OrderService = Depends(Provide[Container.order_service]), +): + return await order_service.get(item_id) diff --git a/tom_calculator/main.py b/tom_calculator/main.py new file mode 100644 index 0000000..9e930d4 --- /dev/null +++ b/tom_calculator/main.py @@ -0,0 +1,7 @@ +import uvicorn + +from tom_calculator.util import set_debug + +if __name__ == '__main__': + set_debug() + uvicorn.run('tom_calculator.application:app', host='0.0.0.0', port=80) diff --git a/tom_calculator/models.py b/tom_calculator/models.py new file mode 100644 index 0000000..b29afda --- /dev/null +++ b/tom_calculator/models.py @@ -0,0 +1,104 @@ +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import MONEY, UUID + +from tom_calculator.database import Base + + +class Tax(Base): + __tablename__ = 'taxes' + __table_args__ = {'comment': 'Table with tax rates per state.'} + + id = sa.Column( + sa.Integer, + primary_key=True, + doc='Tax identifier.', + comment='Tax identifier.', + ) + state_name = sa.Column( + sa.String(20), + nullable=False, + index=True, + unique=True, + doc='Name of the state.', + comment='Name of the state.', + ) + rate = sa.Column( + sa.Numeric(16, 2), + nullable=False, + doc='Rate for the current state measured in `%`.', + comment='Rate for the current state measured in `%`.', + ) + + +class Discount(Base): + __tablename__ = 'discounts' + __table_args__ = {'comment': 'Table with discount rates applied to the amount.'} + + id = sa.Column( + sa.Integer, + primary_key=True, + doc='Dicsount identifier.', + comment='Dicsount identifier.', + ) + amount = sa.Column( + MONEY, + nullable=False, + index=True, + doc='Amount for which this discount is applicable.', + comment='Amount for which this discount is applicable.', + ) + rate = sa.Column( + sa.Numeric(16, 0), + nullable=False, + doc='Discount rate applied to amount measured in `%`.', + comment='Discount rate applied to amount measured in `%`.', + ) + + +class Order(Base): + __tablename__ = 'orders' + __table_args__ = {'comment': 'Table with orders.'} + __mapper_args__ = {'eager_defaults': True} + + id = sa.Column( + UUID, + primary_key=True, + server_default=sa.func.uuid_generate_v4(), + doc='Order identifier.', + comment='Order identifier.', + ) + ts = sa.Column( + sa.DateTime, + server_default=sa.func.now(), + nullable=False, + index=True, + doc='Order timestamp.', + comment='Order timestamp.', + ) + amount = sa.Column( + MONEY, + nullable=False, + doc='Amount of order received from calculator.', + comment='Amount of order received from calculator.', + ) + # after_discount = amount - discount + after_discount = sa.Column( + MONEY, + nullable=False, + doc='Amount of order after discount has been applied.', + comment='Amount of order after discount has been applied.', + ) + # tax = after_discount * rate + tax = sa.Column( + MONEY, + nullable=False, + doc='Tax sum calculated from discounted amount.', + comment='Tax sum calculated from discounted amount.', + ) + # total = after_discount - tax + total = sa.Column( + MONEY, + nullable=False, + doc='Total amount of order after discount and tax.', + comment='Total amount of order after discount and tax.', + ) diff --git a/tom_calculator/schemas.py b/tom_calculator/schemas.py new file mode 100644 index 0000000..dde70a1 --- /dev/null +++ b/tom_calculator/schemas.py @@ -0,0 +1,61 @@ +import datetime +from decimal import Decimal +from typing import List +from uuid import UUID + +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI() + + +class TaxItem(BaseModel): + """Tax Item.""" + state_name: str + rate: Decimal + + +class TaxItemIn(TaxItem): + """Tax Item input.""" + pass + + +class TaxItemOut(TaxItem): + """Tax Item output.""" + id: int + + +class DiscountItem(BaseModel): + """Discount Item.""" + amount: Decimal + rate: int + + +class DiscountItemIn(DiscountItem): + """Discount Item input.""" + + +class DiscountItemOut(DiscountItem): + """Discount Item output.""" + id: int + + +class OrderItemOut(BaseModel): + """Order Item output.""" + id: UUID + ts: datetime.datetime + amount: Decimal + after_discount: Decimal + tax: Decimal + total: Decimal + + +class CalculatorItemIn(BaseModel): + """Calculator Item input.""" + quantity: int + price: Decimal + + +class CalculatorIn(BaseModel): + """Calculator input.""" + items: List[CalculatorItemIn] diff --git a/tom_calculator/services.py b/tom_calculator/services.py new file mode 100644 index 0000000..0ce9a22 --- /dev/null +++ b/tom_calculator/services.py @@ -0,0 +1,78 @@ +import asyncio +import logging +from decimal import Decimal +from pathlib import Path +from typing import Any +from uuid import UUID + +from dependency_injector.wiring import Provide + +from tom_calculator.database import TSession +from tom_calculator.util import load_csv + +logger = logging.getLogger(__name__) + + +class ServiceWithSession: + def __init__(self, session: TSession) -> None: + self._session = session + + +class DiscountService(ServiceWithSession): + """Discount service.""" + async def get_discount_by_amount(self, amount: Decimal) -> int: + """Get the best discount rate by the given amount.""" + return 0 + + async def load_data(self, items: Any) -> None: + """Load data.""" + + +class TaxService(ServiceWithSession): + """Tax service.""" + async def get_rate_by_state(self, state_name: str) -> Decimal: + """Get rate of the given state name.""" + return 0 + + async def load_data(self, items: Any) -> None: + """Load data.""" + + +class OrderService(ServiceWithSession): + """Order service.""" + discount_service: DiscountService = Provide['discount_service'] + tax_service: TaxService = Provide['tax_service'] + + async def create(self, item: dict) -> dict: + """Create order.""" + async with self._session() as session: + await session.commit() + return {} + + async def get(self, item_id: UUID): + """Get order.""" + async with self._session() as session: + await session.commit() + + +class LoaderService: + """Loader service.""" + discount_service: DiscountService = Provide['discount_service'] + tax_service: TaxService = Provide['tax_service'] + + async def load(self, datadir: str) -> None: + """Load data from datadir.""" + discount_file = Path(datadir) / 'discounts.csv' + discount_items = await LoaderService.run_blocking_io(load_csv, discount_file) + await self.discount_service.load_data(discount_items) + + tax_file = Path(datadir) / 'taxes.csv' + tax_items = await LoaderService.run_blocking_io(load_csv, tax_file) + await self.tax_service.load_data(tax_items) + + @staticmethod + async def run_blocking_io(self, func, *args) -> Any: + """Run blocking I/O in executor.""" + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(None, func, *args) + return result diff --git a/tom_calculator/util.py b/tom_calculator/util.py new file mode 100644 index 0000000..d8e795f --- /dev/null +++ b/tom_calculator/util.py @@ -0,0 +1,34 @@ +import csv +import os +from pathlib import Path +from typing import Any + +import pydevd_pycharm + + +def get_config_path(): + """Get configuration file's path from the environment variable.""" + return Path(os.getenv('TOM_CONFIG')) + + +def load_csv(path: Path) -> Any: + """Load data from csv file.""" + with open(path, newline='') as csvfile: + reader = csv.DictReader(csvfile) + items = list(reader) + return items + + +def set_debug(): + """Set debug from env.""" + if os.getenv("DEBUG_PYDEVD"): + host, port = os.getenv("DEBUG_PYDEVD", "").split(":") + port_int: int = int(port) + + pydevd_pycharm.settrace( + host=host, + stdoutToServer=True, + stderrToServer=True, + port=port_int, + suspend=False, + )