From f952812cf309aff6c3d4804e3835dc6b9339ee43 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 19 Apr 2023 14:31:39 +0300 Subject: [PATCH 01/18] included the alembic for migrations --- alembic.ini | 110 +++++++++++++++++++++ migration/README | 1 + migration/env.py | 77 +++++++++++++++ migration/script.py.mako | 24 +++++ migration/versions/86f9d6304908_initial.py | 77 +++++++++++++++ 5 files changed, 289 insertions(+) create mode 100644 alembic.ini create mode 100644 migration/README create mode 100644 migration/env.py create mode 100644 migration/script.py.mako create mode 100644 migration/versions/86f9d6304908_initial.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..ba98e5f --- /dev/null +++ b/alembic.ini @@ -0,0 +1,110 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migration + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(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. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# 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 migration/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migration/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = postgresql://postgres:123456@localhost/finances + + +[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/migration/README b/migration/README new file mode 100644 index 0000000..2500aa1 --- /dev/null +++ b/migration/README @@ -0,0 +1 @@ +Generic single-database configuration. diff --git a/migration/env.py b/migration/env.py new file mode 100644 index 0000000..2fbb470 --- /dev/null +++ b/migration/env.py @@ -0,0 +1,77 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from src.database import DatabaseFactory + +# 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. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +database_factory = DatabaseFactory() +target_metadata = database_factory.mapper_registry.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() -> None: + """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() -> None: + """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/migration/script.py.mako b/migration/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/migration/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() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migration/versions/86f9d6304908_initial.py b/migration/versions/86f9d6304908_initial.py new file mode 100644 index 0000000..24e65e7 --- /dev/null +++ b/migration/versions/86f9d6304908_initial.py @@ -0,0 +1,77 @@ +"""initial + +Revision ID: 86f9d6304908 +Revises: +Create Date: 2023-04-19 13:59:59.584998 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "86f9d6304908" +down_revision = None +branch_labels = None +depends_on = 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=False), + sa.Column("hashed_password", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + op.create_table( + "banks_info", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("bank_name", sa.String(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "operations", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("amount", sa.Integer(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("time", sa.Integer(), nullable=False), + sa.Column("mcc", sa.Integer(), nullable=True), + sa.Column("source_type", sa.String(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "banks_info_properties", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("value", sa.String(), nullable=False), + sa.Column("type", sa.String(), nullable=False), + sa.Column("manager_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["manager_id"], + ["banks_info.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("banks_info_properties") + op.drop_table("operations") + op.drop_table("banks_info") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") + # ### end Alembic commands ### From a7f53bab4b8cd11850948f0f13edfd674a5aa6a2 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 19 Apr 2023 14:32:41 +0300 Subject: [PATCH 02/18] included CORS middleware --- src/main.py | 2 ++ src/middlewares.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 src/middlewares.py diff --git a/src/main.py b/src/main.py index efdcb3b..edad578 100644 --- a/src/main.py +++ b/src/main.py @@ -4,12 +4,14 @@ from fastapi import FastAPI from src.database import DatabaseFactory, bind_database_to_app +from src.middlewares import set_cors_middleware def bootstrap_fastapi_app(db_factory=DatabaseFactory(), test=False) -> FastAPI: """Налаштування FastApi для початку роботи, включаючи базу""" fastapi_app = FastAPI() include_routers(fastapi_app) + set_cors_middleware(fastapi_app) database = db_factory.get_database(test=test) bind_database_to_app(fastapi_app, database) # Прив'язка залежності, яка віддає сесію бази diff --git a/src/middlewares.py b/src/middlewares.py new file mode 100644 index 0000000..f199915 --- /dev/null +++ b/src/middlewares.py @@ -0,0 +1,14 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + + +def set_cors_middleware(app: FastAPI): + origins = ["*"] + + app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) From fa80c9333ebb686e000c6f38eb675c9f7cfa0417 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Tue, 25 Apr 2023 11:23:35 +0300 Subject: [PATCH 03/18] deleted most_popular_category field from statistic --- src/app/domain/statistic.py | 1 - src/app/services/statistic.py | 6 ------ src/schemas/statistic.py | 1 - 3 files changed, 8 deletions(-) diff --git a/src/app/domain/statistic.py b/src/app/domain/statistic.py index 9612808..6274dc3 100644 --- a/src/app/domain/statistic.py +++ b/src/app/domain/statistic.py @@ -4,5 +4,4 @@ @dataclass class Statistic: costs_sum: int - most_popular_category: int categories_costs: dict[int, int] diff --git a/src/app/services/statistic.py b/src/app/services/statistic.py index 8d11150..2debe21 100644 --- a/src/app/services/statistic.py +++ b/src/app/services/statistic.py @@ -10,7 +10,6 @@ def get_statistic(operations: list[Operation]) -> Statistic: """ statistic = Statistic( costs_sum=get_costs_sum(operations), - most_popular_category=get_most_popular_category(operations), categories_costs=get_categories_costs(operations), ) return statistic @@ -21,11 +20,6 @@ def get_costs_sum(operations: list[Operation]) -> int: return sum(operation.amount for operation in operations) -def get_most_popular_category(operations: list[Operation]) -> int: - """Категорія з найбільшою витратою""" - return max(operations, key=lambda operation: operation.amount).mcc - - def get_categories_costs(operations: list[Operation]) -> dict[int, int]: """Словник у форматі {<категорія>: <сума витрат по категорії>}""" operations_mcc = [operation.mcc for operation in operations] diff --git a/src/schemas/statistic.py b/src/schemas/statistic.py index fbe1fa5..66434cc 100644 --- a/src/schemas/statistic.py +++ b/src/schemas/statistic.py @@ -5,5 +5,4 @@ class StatisticSchema(BaseModel): """Схема операції. Модель: src.app.domain.unit.Statistic""" costs_sum: int - most_popular_category: int categories_costs: dict[int, int] From 53f355ae421d1b8b2a9201af0cb516e27d4b5d39 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Tue, 25 Apr 2023 11:24:18 +0300 Subject: [PATCH 04/18] added statistic tests --- tests/unit/statistic.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/statistic.py b/tests/unit/statistic.py index 20767ed..318bef6 100644 --- a/tests/unit/statistic.py +++ b/tests/unit/statistic.py @@ -23,3 +23,12 @@ async def test_get_statistic(): ] get_statistic(operations) assert True + + +@pytest.mark.asyncio +async def test_get_statistic_with_empty_operations(): + operations = [] + statictic = get_statistic(operations) + + assert statictic.costs_sum == 0 + assert statictic.categories_costs == {} From 5ac529d66d025d5c88e213b6290ab934637afc06 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 26 Apr 2023 11:08:03 +0300 Subject: [PATCH 05/18] create default time values in get_operations method --- src/app/repositories/absctract/operations.py | 2 +- src/app/services/operations.py | 10 +++++++--- src/routers/statistic.py | 8 +++++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/app/repositories/absctract/operations.py b/src/app/repositories/absctract/operations.py index 9ccb1bb..b7516e2 100644 --- a/src/app/repositories/absctract/operations.py +++ b/src/app/repositories/absctract/operations.py @@ -11,6 +11,6 @@ async def get(self, field, value) -> Operation: @abstractmethod async def get_all_by_user( - self, user_id, from_time: int, to_time: int + self, user_id, from_time: int = None, to_time: int = None ) -> list[Operation]: raise NotImplementedError diff --git a/src/app/services/operations.py b/src/app/services/operations.py index 030bbf7..1637379 100644 --- a/src/app/services/operations.py +++ b/src/app/services/operations.py @@ -24,13 +24,17 @@ async def create_operation( return operation -async def get_operations(uow: AbstractUnitOfWork, user_id: int) -> list[Operation]: +async def get_operations( + uow: AbstractUnitOfWork, user_id: int, from_time=None, to_time=None +) -> list[Operation]: """ - Повертає всі операції які містять переданий user_id в полі user_id + Повертнення всих операцій, які містять переданий user_id в полі user_id :param uow: Unit of Work :param user_id: ID користувача, операції якого потрібно отримати + :param from_time: початковий момент часу + :param to_time: кінцевий момент часу :return: список об'єктів моделі Opetation. Якщо операцій немає, то пустий список """ async with uow: - operations = await uow.operations.get_all_by_user(user_id) + operations = await uow.operations.get_all_by_user(user_id, from_time, to_time) return list(operations) diff --git a/src/routers/statistic.py b/src/routers/statistic.py index facc14d..340a0ec 100644 --- a/src/routers/statistic.py +++ b/src/routers/statistic.py @@ -1,3 +1,5 @@ +from datetime import datetime, timedelta + from fastapi import APIRouter, Depends from src.app.domain.users import User @@ -14,6 +16,10 @@ async def get_statistic_view( current_user: User = Depends(get_current_user_depend), uow: AbstractUnitOfWork = Depends(get_uow), + from_time: int = None, + to_time: int = None, ): - operations = await get_operations(uow, current_user.id) + from_time = from_time if from_time else datetime.today().timestamp() + to_time = to_time if to_time else (datetime.today() + timedelta(days=1)).timestamp() + operations = await get_operations(uow, current_user.id, from_time, to_time) return get_statistic(operations) From 99649175687dfa2509dbb054c598cca0f51c5f4c Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 26 Apr 2023 14:27:55 +0300 Subject: [PATCH 06/18] changed field in update request --- src/app/repositories/bank_api/bank_info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/repositories/bank_api/bank_info.py b/src/app/repositories/bank_api/bank_info.py index c385bc4..330f307 100644 --- a/src/app/repositories/bank_api/bank_info.py +++ b/src/app/repositories/bank_api/bank_info.py @@ -50,7 +50,7 @@ async def set_update_time_to_managers(self, ids: list[int]): await self.session.execute( update(BankInfoProperty) .filter( - BankInfoProperty.manager.in_(ids), + BankInfoProperty.manager_id.in_(ids), BankInfoProperty.name == "updated_time", ) .values(updated_time=datetime.now().timestamp()) From 78ab8130f7b4deaedcff58e8876badaeb3f9b9b5 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 26 Apr 2023 14:28:50 +0300 Subject: [PATCH 07/18] refactored --- src/app/repositories/operations.py | 5 +---- src/app/services/operations.py | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/app/repositories/operations.py b/src/app/repositories/operations.py index d4f0d31..5cb5c3a 100644 --- a/src/app/repositories/operations.py +++ b/src/app/repositories/operations.py @@ -12,10 +12,7 @@ async def get(self, field, value) -> Operation: return await self._get(Operation, field, value) async def get_all_by_user( - self, - user_id, - from_time: int = None, - to_time: int = None, + self, user_id, from_time: int = None, to_time: int = None ) -> list[Operation]: from_time = ( from_time diff --git a/src/app/services/operations.py b/src/app/services/operations.py index 1637379..4ca44e2 100644 --- a/src/app/services/operations.py +++ b/src/app/services/operations.py @@ -36,5 +36,4 @@ async def get_operations( :return: список об'єктів моделі Opetation. Якщо операцій немає, то пустий список """ async with uow: - operations = await uow.operations.get_all_by_user(user_id, from_time, to_time) - return list(operations) + return await uow.operations.get_all_by_user(user_id, from_time, to_time) From b4e011f0c943c5def8b2273c559a6a9942e2032b Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 26 Apr 2023 14:29:36 +0300 Subject: [PATCH 08/18] added validation --- src/app/services/bank_api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/services/bank_api.py b/src/app/services/bank_api.py index 012ca4c..8eb1d7e 100644 --- a/src/app/services/bank_api.py +++ b/src/app/services/bank_api.py @@ -52,6 +52,8 @@ async def get_bank_managers_by_user( async def update_banks_costs( uow: AbstractUnitOfWork, managers: list[ABankManagerRepository] ): + if len(managers) == 0: + return async with uow: costs = [] updated_managers = [] From adcb305c7e41ccf8bd4a6728ba7452dcd703ca8c Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 27 Apr 2023 12:15:47 +0300 Subject: [PATCH 09/18] added test read operations in date range --- tests/unit/operations_test.py | 40 +++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/unit/operations_test.py b/tests/unit/operations_test.py index 0186761..63b6e68 100644 --- a/tests/unit/operations_test.py +++ b/tests/unit/operations_test.py @@ -1,11 +1,11 @@ import random import time -from datetime import datetime +from datetime import datetime, timedelta import pytest from src.app.domain.operations import Operation -from src.app.services.operations import create_operation +from src.app.services.operations import create_operation, get_operations from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork from src.app.services.users import create_user from src.schemas.operations import OperationCreateSchema @@ -65,3 +65,39 @@ async def test_read_operations(database): # noqa: F811; assert isinstance(operations, list) assert isinstance(operations[0], Operation) assert len(operations) == 10 + + +@pytest.mark.asyncio +@precents_evn_variables +async def test_read_operations_in_date_range(database): + async with database.sessionmaker() as session: + uow = SqlAlchemyUnitOfWork(session) + user_schema = UserCreateSchema(email="test", password="test") # nosec B106 + user = await create_user(uow, user_schema) + + todays_operation_schema = OperationCreateSchema( + amount=random.randint(-10000, -10), + description="description", + time=datetime.now().timestamp(), + mcc=random.randint(1000, 9999), + source_type="manual", + ) + await create_operation(uow, user.id, todays_operation_schema) + async with uow: + yesterdays_operation = Operation( + amount=random.randint(-10000, -10), + description="description", + time=int((datetime.now() - timedelta(days=1)).timestamp()), + mcc=random.randint(1000, 9999), + source_type="manual", + user_id=user.id, + ) + await uow.operations.add(yesterdays_operation) + await uow.commit() + + today = datetime.today().timestamp() + tomorrow = (datetime.today() + timedelta(days=1)).timestamp() + operations = await get_operations( + uow, user.id, from_time=int(today), to_time=int(tomorrow) + ) + assert len(operations) == 1 From dfad1462f034ffce77bf6843256458650d4ca908 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 28 Apr 2023 10:25:14 +0300 Subject: [PATCH 10/18] deleted default time initialize in repository method --- src/app/repositories/operations.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/app/repositories/operations.py b/src/app/repositories/operations.py index 5cb5c3a..0a768be 100644 --- a/src/app/repositories/operations.py +++ b/src/app/repositories/operations.py @@ -1,5 +1,3 @@ -from datetime import datetime, timedelta - from sqlalchemy import select from src.app.domain.operations import Operation @@ -12,14 +10,8 @@ async def get(self, field, value) -> Operation: return await self._get(Operation, field, value) async def get_all_by_user( - self, user_id, from_time: int = None, to_time: int = None + self, user_id, from_time: int, to_time: int ) -> list[Operation]: - from_time = ( - from_time - if from_time - else int((datetime.now() - timedelta(days=1)).timestamp()) - ) - to_time = to_time if to_time else int(datetime.now().timestamp()) return list( await self.session.scalars( select(Operation) From 940da26a86606f4d8b76a2ca06f320a7433be78a Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 28 Apr 2023 10:25:46 +0300 Subject: [PATCH 11/18] added default time initialize in router endpoint --- src/routers/operations.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/routers/operations.py b/src/routers/operations.py index cbe91e4..f482d64 100644 --- a/src/routers/operations.py +++ b/src/routers/operations.py @@ -1,3 +1,5 @@ +from datetime import datetime, timedelta + from fastapi import APIRouter, Depends from src.app.domain.users import User @@ -34,6 +36,8 @@ async def create_operation_view( async def read_operations_view( current_user: User = Depends(get_current_user_depend), uow: AbstractUnitOfWork = Depends(get_uow), + from_time: int | None = None, + to_time: int | None = None, ): """ Повертає список операцій поточного користувача. @@ -41,6 +45,12 @@ async def read_operations_view( :param uow: Unit of Work :param current_user: Користувач, який розшифровується з токену у заголовку Authorization + :param from_time: з якого часу в unix форматі + :param to_time: по який час в unix форматі :return: Operation list """ - return await get_operations(uow, current_user.id) + from_time = from_time if from_time else int(datetime.today().timestamp()) + to_time = ( + to_time if to_time else int((datetime.today() + timedelta(days=1)).timestamp()) + ) + return await get_operations(uow, current_user.id, from_time, to_time) From fbe2e54774a8d272c1ed8eaf6202b15ecf7f7905 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 28 Apr 2023 10:26:08 +0300 Subject: [PATCH 12/18] deleted default time initialize in repository method --- src/app/repositories/absctract/operations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/repositories/absctract/operations.py b/src/app/repositories/absctract/operations.py index b7516e2..9ccb1bb 100644 --- a/src/app/repositories/absctract/operations.py +++ b/src/app/repositories/absctract/operations.py @@ -11,6 +11,6 @@ async def get(self, field, value) -> Operation: @abstractmethod async def get_all_by_user( - self, user_id, from_time: int = None, to_time: int = None + self, user_id, from_time: int, to_time: int ) -> list[Operation]: raise NotImplementedError From 5b797b4b5b81afa4c85ed66b27e021a312269871 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 28 Apr 2023 10:32:48 +0300 Subject: [PATCH 13/18] added the ability to set the time of the operation during creation --- src/app/services/operations.py | 5 ++--- src/schemas/operations.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/services/operations.py b/src/app/services/operations.py index 4ca44e2..2375a0b 100644 --- a/src/app/services/operations.py +++ b/src/app/services/operations.py @@ -16,9 +16,8 @@ async def create_operation( :return: Operation якщо user_id існує в базі. None, якщо ні """ async with uow: - operation = Operation( - **schema.dict(), time=int(datetime.now().timestamp()), user_id=user_id - ) + schema.time = schema.time if schema.time else int(datetime.now().timestamp()) + operation = Operation(**schema.dict(), user_id=user_id) await uow.operations.add(operation) await uow.commit() return operation diff --git a/src/schemas/operations.py b/src/schemas/operations.py index c77e459..2dcafca 100644 --- a/src/schemas/operations.py +++ b/src/schemas/operations.py @@ -11,13 +11,13 @@ class OperationCreateSchema(BaseModel): description: str | None mcc: int source_type: str + time: int | None class OperationSchema(OperationCreateSchema): """Схема операції, яка виокристовується під час завантаження даних з БД""" id: int - time: int class Config: orm_mode = True From eb5d3b884ca317bdcb5e3f02d87daf6de5adfd33 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sat, 29 Apr 2023 10:06:33 +0300 Subject: [PATCH 14/18] added costs_num_by_days field to statistic --- src/app/domain/statistic.py | 1 + src/app/services/statistic.py | 13 +++++++++++ src/schemas/statistic.py | 1 + tests/unit/statistic.py | 43 +++++++++++++++++++++++++++++++---- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/app/domain/statistic.py b/src/app/domain/statistic.py index 6274dc3..7123bcb 100644 --- a/src/app/domain/statistic.py +++ b/src/app/domain/statistic.py @@ -5,3 +5,4 @@ class Statistic: costs_sum: int categories_costs: dict[int, int] + costs_num_by_days: dict[str, int] diff --git a/src/app/services/statistic.py b/src/app/services/statistic.py index 2debe21..8ee5bb5 100644 --- a/src/app/services/statistic.py +++ b/src/app/services/statistic.py @@ -1,3 +1,6 @@ +from collections import Counter +from datetime import datetime + from src.app.domain.operations import Operation from src.app.domain.statistic import Statistic @@ -11,6 +14,7 @@ def get_statistic(operations: list[Operation]) -> Statistic: statistic = Statistic( costs_sum=get_costs_sum(operations), categories_costs=get_categories_costs(operations), + costs_num_by_days=get_costs_num_by_days(operations), ) return statistic @@ -29,3 +33,12 @@ def get_categories_costs(operations: list[Operation]) -> dict[int, int]: ) for mcc in operations_mcc } + + +def get_costs_num_by_days(operations: list[Operation]) -> dict[str, int]: + costs_num_by_days = Counter() + for operation in operations: + costs_num_by_days[ + datetime.fromtimestamp(operation.time).strftime("%Y-%m-%d") + ] += 1 + return dict(costs_num_by_days) diff --git a/src/schemas/statistic.py b/src/schemas/statistic.py index 66434cc..ef52349 100644 --- a/src/schemas/statistic.py +++ b/src/schemas/statistic.py @@ -6,3 +6,4 @@ class StatisticSchema(BaseModel): costs_sum: int categories_costs: dict[int, int] + costs_num_by_days: dict[str, int] diff --git a/tests/unit/statistic.py b/tests/unit/statistic.py index 318bef6..5977be1 100644 --- a/tests/unit/statistic.py +++ b/tests/unit/statistic.py @@ -1,5 +1,6 @@ -import random -from datetime import datetime +from collections import Counter +from datetime import datetime, timedelta +from random import randint import pytest @@ -12,9 +13,9 @@ async def test_get_statistic(): # TODO: Переписати без рандомних операцій і перевірити результат operations = [ Operation( - amount=random.randint(-10000, -10), + amount=randint(-10000, -10), description="description", - mcc=random.randint(9996, 9999), + mcc=randint(9996, 9999), source_type="manual", time=int(datetime.now().timestamp()), user_id=1, @@ -32,3 +33,37 @@ async def test_get_statistic_with_empty_operations(): assert statictic.costs_sum == 0 assert statictic.categories_costs == {} + + +@pytest.mark.asyncio +async def test_statistic_by_days(): + """ + Статистика повинна містити в собі поле кількості операцій + на кожен день у наступному вигляді: + + costs_num_by_days: dict[: str, : int] + + В тесті створюються операції, записується їх кількість по дням + а потім йде перевірка на відповідність зі отриманою статистикою + """ + operations_num_by_days = {} + operations = [] + now = datetime.now() + date = datetime(now.year, now.month, now.day) - timedelta(days=6) + for _ in range(5): + date = date + timedelta(days=1) + opertations_num = randint(1, 10) + operations_num_by_days[date.strftime("%Y-%m-%d")] = opertations_num + operations += [ + Operation( + amount=randint(-10000, -10), + description="description", + mcc=randint(9990, 9999), + source_type="manual", + time=int(date.timestamp() + randint(1, 8399)), # random time of day + user_id=1, + ) + for _ in range(opertations_num) + ] + statistic = get_statistic(operations) + assert statistic.costs_num_by_days == operations_num_by_days From 8cae8e715ca61edf7f57e87b048228001dcc8549 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 30 Apr 2023 18:31:29 +0300 Subject: [PATCH 15/18] added sync version of database URL --- src/database.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/database.py b/src/database.py index b83c5db..7ab566b 100644 --- a/src/database.py +++ b/src/database.py @@ -10,7 +10,7 @@ from src.app.adapters.orm import create_tables, start_mappers -def _get_url(test: bool = False) -> str: +def _get_url(test: bool = False, async_: bool = True) -> str: """ :param test: Якщо True, то підставляє назву тестової бази в URL :return: URL для підлключення до БД @@ -21,7 +21,11 @@ def _get_url(test: bool = False) -> str: os.getenv("DB_PASSWORD"), os.getenv("DB_HOST"), ) - return f"postgresql+asyncpg://{db_user}:{db_password}@{db_host}/{db_name}" + return ( + f"postgresql+asyncpg://{db_user}:{db_password}@{db_host}/{db_name}" + if async_ + else f"postgresql+psycopg2://{db_user}:{db_password}@{db_host}/{db_name}" + ) class Database: From 3abbb1527b99fd5da704c6db73fc6976159adcf2 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 30 Apr 2023 18:31:55 +0300 Subject: [PATCH 16/18] added playwright tests --- requirements.txt | Bin 3146 -> 3218 bytes tests_playwright/__init__.py | 0 tests_playwright/test_auth.py | 78 ++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 tests_playwright/__init__.py create mode 100644 tests_playwright/test_auth.py diff --git a/requirements.txt b/requirements.txt index a26ea1b2679999a000068a7cf0335ba02c8680b4..3c6432bdfb22ece898d1d0630321aa4e07d31996 100644 GIT binary patch delta 80 zcmX>lF-dZR7mo)QLn1>CLn=cqLlQ$KLo!1hgDrz0gB}nY07+v8E`|c2d?G_7Lpehc UP(?aJ215y4l`(@6SS=R=0ELMS%m4rY delta 7 OcmbOvc}ik~7Y_gnodT2q diff --git a/tests_playwright/__init__.py b/tests_playwright/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests_playwright/test_auth.py b/tests_playwright/test_auth.py new file mode 100644 index 0000000..dc5fdd4 --- /dev/null +++ b/tests_playwright/test_auth.py @@ -0,0 +1,78 @@ +import re +import time + +from playwright.sync_api import Page +from sqlalchemy import create_engine, text + +from src.database import _get_url + + +def delete_user(): + engine = create_engine(_get_url(async_=False)) + with engine.connect() as connection: + user_id = connection.execute( + text("SELECT id FROM public.users WHERE email = 'test';") + ).first() + user_id = user_id[0] if user_id else None + if user_id: + connection.execute( + text(f"DELETE FROM public.operations WHERE user_id = {user_id};") + ) + connection.execute(text("DELETE FROM public.users WHERE email = 'test';")) + connection.commit() + + +def login(page): + page.get_by_role("button", name="Sign In").click() + page.get_by_placeholder("email").fill("test") + page.get_by_placeholder("password").click() + page.get_by_placeholder("password").fill("test") + page.get_by_role("button", name="Login").click() + + +def test_auth(page: Page) -> None: + delete_user() + page.goto("http://127.0.0.1:3000/") + page.get_by_role("button", name="Sign Up").click() + page.get_by_placeholder("email").fill("test") + page.get_by_placeholder("password", exact=True).click() + page.get_by_placeholder("password", exact=True).fill("test") + page.get_by_placeholder("confirm password").click() + page.get_by_placeholder("confirm password").fill("test") + page.get_by_role("button", name="Register").click() + login(page) + assert page.get_by_role("button", name="Додати").is_enabled() + + +def test_add_operation(page: Page) -> None: + page.goto("http://127.0.0.1:3000/") + login(page) + page.get_by_role("button", name="Додати").click() + page.get_by_placeholder("сума").fill("100") + page.get_by_placeholder("опис").fill("test") + page.locator("svg").click() + time.sleep(0.3) + page.get_by_text("Ветеринарні послуги", exact=True).last.click() + page.get_by_role("button", name="Додати").click() + time.sleep(0.3) + assert ( + page.get_by_text( + re.compile("-100 ₴testВетеринарні послугиручний спосіб") + ).count() + > 0 + ) + + +def test_add_old_operation(page: Page) -> None: + page.goto("http://127.0.0.1:3000/") + login(page) + page.get_by_role("button", name="Додати").click() + page.get_by_placeholder("сума").fill("100") + page.get_by_placeholder("опис").fill("test") + page.get_by_placeholder("час").fill("2023-04-25T05:15") + page.locator("svg").click() + time.sleep(0.3) + page.get_by_text("Продукти", exact=True).click() + page.get_by_role("button", name="Додати").click() + page.get_by_placeholder("dashboard-time").fill("2023-04-25") + assert page.get_by_text("-100 ₴testПродуктиручний спосіб05:15").count() == 1 From bd0014db8adac79b033ad49ba46a135046f5e5e1 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 30 Apr 2023 18:33:00 +0300 Subject: [PATCH 17/18] change pytest command in GitHub Action script --- .github/workflows/python-app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 3d86958..90667b7 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -50,4 +50,4 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Test with pytest run: | - pytest + pytest tests From 5f6c627b75bf7cd3a90df33aba7907a74bc34141 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 30 Apr 2023 18:36:31 +0300 Subject: [PATCH 18/18] changed greenlet version from 2.0.2 to 2.0.1 --- requirements.txt | Bin 3218 -> 3218 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 3c6432bdfb22ece898d1d0630321aa4e07d31996..11cc0783516737d11c8163482fc59970e75b11e4 100644 GIT binary patch delta 14 VcmbOvIZ1Lu4GW{;=2{j}4ge!`1PTBE delta 14 VcmbOvIZ1Lu4GW{u=2{j}4ge#11PcHF