From 4b0673c4675b62a813d49693bb20c2771b06e6b4 Mon Sep 17 00:00:00 2001 From: matv864 Date: Thu, 13 Mar 2025 17:54:38 +1000 Subject: [PATCH] start project --- .dockerignore | 31 ++++ .env.example | 5 + .gitignore | 28 ++++ .gitlab-ci.yml | 100 ++++++++++++ Dockerfile | 17 +++ Makefile | 25 +++ README.md | 15 ++ alembic/db1/alembic.ini | 115 ++++++++++++++ alembic/db1/env.py | 90 +++++++++++ alembic/db1/script.py.mako | 26 ++++ alembic/db2/alembic.ini | 115 ++++++++++++++ alembic/db2/env.py | 92 +++++++++++ alembic/db2/script.py.mako | 26 ++++ .../versions/2025_03_13_1743-581af68b8c68_.py | 37 +++++ compose.yaml | 52 +++++++ .../create-multiple-postgresql-databases.sh | 27 ++++ database/dumps/db1.sql | 0 database/dumps/db2.sql | 0 pyproject.toml | 43 ++++++ requirements.lock | 128 ++++++++++++++++ src/__init__.py | 0 src/adapters/database/models/__init__.py | 2 + src/adapters/database/models/common.py | 17 +++ src/adapters/database/models/db1/base.py | 10 ++ src/adapters/database/models/db1/blog.py | 0 src/adapters/database/models/db1/post.py | 0 src/adapters/database/models/db1/user.py | 0 src/adapters/database/models/db2/base.py | 11 ++ src/adapters/database/models/db2/log.py | 16 ++ src/adapters/database/repositories.py | 23 +++ src/adapters/database/repository_gateway.py | 10 ++ src/adapters/database/session.py | 8 + src/api/__init__.py | 1 + src/api/healthcheck.py | 8 + src/app.py | 23 +++ src/settings.py | 32 ++++ src/unit_of_work.py | 44 ++++++ src/utils/__init__.py | 0 src/utils/dependencies.py | 20 +++ src/utils/exceptions.py | 52 +++++++ src/utils/repository.py | 143 ++++++++++++++++++ src/utils/time.py | 5 + src/utils/validating_base64.py | 13 ++ src/utils/verification.py | 9 ++ 44 files changed, 1419 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 alembic/db1/alembic.ini create mode 100644 alembic/db1/env.py create mode 100644 alembic/db1/script.py.mako create mode 100644 alembic/db2/alembic.ini create mode 100644 alembic/db2/env.py create mode 100644 alembic/db2/script.py.mako create mode 100644 alembic/db2/versions/2025_03_13_1743-581af68b8c68_.py create mode 100644 compose.yaml create mode 100644 database/create-multiple-postgresql-databases.sh create mode 100644 database/dumps/db1.sql create mode 100644 database/dumps/db2.sql create mode 100644 pyproject.toml create mode 100644 requirements.lock create mode 100644 src/__init__.py create mode 100644 src/adapters/database/models/__init__.py create mode 100644 src/adapters/database/models/common.py create mode 100644 src/adapters/database/models/db1/base.py create mode 100644 src/adapters/database/models/db1/blog.py create mode 100644 src/adapters/database/models/db1/post.py create mode 100644 src/adapters/database/models/db1/user.py create mode 100644 src/adapters/database/models/db2/base.py create mode 100644 src/adapters/database/models/db2/log.py create mode 100644 src/adapters/database/repositories.py create mode 100644 src/adapters/database/repository_gateway.py create mode 100644 src/adapters/database/session.py create mode 100644 src/api/__init__.py create mode 100644 src/api/healthcheck.py create mode 100644 src/app.py create mode 100644 src/settings.py create mode 100644 src/unit_of_work.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/dependencies.py create mode 100644 src/utils/exceptions.py create mode 100644 src/utils/repository.py create mode 100644 src/utils/time.py create mode 100644 src/utils/validating_base64.py create mode 100644 src/utils/verification.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0bc50f7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# python generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# venv +.venv + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# ruff +.ruff_cache/ + +# rye +requirements-dev.lock + +# Docker compose +compose.yaml +compose-dev.yaml + +# minio and db dump +dumps + +# env (pass it thru compose) +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6341025 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +POSTGRES_HOST=localhost +POSTGRES_DB1=db1 +POSTGRES_DB2=db2 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f79f95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# python generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info +.idea +# venv +.venv + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# ruff +.ruff_cache/ + +# rye +requirements-dev.lock + +# evironment +*.env +db.yaml + +# object storage +dumps/minio/* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..ca9f89c --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,100 @@ +stages: + - lint + - build + - backup + - deploy + +.configure_ssh: + before_script: + # Run ssh-agent for keys management + - 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )' + - eval $(ssh-agent -s) + # Add place for ssh related files + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + # Initialize token + - chmod 400 "$SSH_PRIVATE_KEY" + - ssh-add "$SSH_PRIVATE_KEY" + # Add server fingerprint to known hosts + - ssh-keyscan "$SSH_HOST" >> ~/.ssh/known_hosts + - chmod 644 ~/.ssh/known_hosts + +.on_merge_request: + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - when: never + +lint-ruff: + stage: lint + image: registry.gitlab.com/pipeline-components/ruff:latest + rules: + - !reference [.on_merge_request, rules] + script: + - echo "☕ Linting with ruff" + - ruff check --output-format=gitlab src/ + - echo "✅ Passed" + +lint-mypy: + stage: lint + image: python:3.12 + rules: + - !reference [.on_merge_request, rules] + before_script: + - pip install mypy + - apt install make + - make deps + script: + - echo "🐍 Typechecking with mypy" + - mypy src + - echo "✅ Passed" + +build: + stage: build + image: docker:latest + services: + - docker:dind + before_script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + script: + - docker pull $CI_REGISTRY_IMAGE:latest || true + - docker build --target prod --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA --tag $CI_REGISTRY_IMAGE:latest . + - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA + - docker push $CI_REGISTRY_IMAGE:latest + +database-backup: + stage: backup + image: ubuntu:latest + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + before_script: + - !reference [.configure_ssh, before_script] + script: + - echo "💾 backuping database" + - ssh $SSH_USER@$SSH_HOST "docker exec database pg_dump --column-inserts udom >> pre_deploy.sql" + - echo "✅ Passed" + +deploy-dev: + stage: deploy + image: ubuntu:latest + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + before_script: + - !reference [.configure_ssh, before_script] + script: + - echo "🚀🧨 Deploing dev changes" + - ssh $SSH_USER@$SSH_HOST "cd /root/udom_dev/ && git pull && docker compose -f compose-dev.yaml up -d --build --remove-orphans" + - echo "✅ Passed" + +deploy-main: + stage: deploy + image: ubuntu:latest + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + before_script: + - !reference [.configure_ssh, before_script] + script: + - echo "🚀 Deploing changes" + - ssh $SSH_USER@$SSH_HOST "cd /root/udom/ && git pull && echo $SERVER_TOKEN | docker login registry.gitlab.com -u 'Server' --password-stdin && docker compose pull && docker compose up -d --build --remove-orphans" + - echo "✅ Passed" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e360b7d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-alpine AS base + +WORKDIR /backend + +# README.md needed to hatchling build +COPY pyproject.toml requirements.lock README.md . +RUN pip install uv --no-cache +RUN uv pip install --no-cache --system -r requirements.lock + +COPY . . + +FROM base AS dev +CMD ["granian", "--interface", "asgi", "src/app.py", "--reload", "--host", "0.0.0.0"] + +FROM base AS prod +CMD ["granian", "--interface", "asgi", "src/app.py", "--host", "0.0.0.0"] + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b9198e9 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +deps: + pip install uv + uv pip install --no-cache --system -r requirements.lock + +deps-dev: + rye sync + +lint: + rye run ruff check src/ + rye run mypy src/ + +migrate: + rye run alembic revision --autogenerate + rye run alembic upgrade head + +format: + rye run isort src/ + rye run ruff format src/ + rye run black src/ + +run: + fastapi run src/app.py --host 0.0.0.0 --reload + +run-dev: + rye run fastapi dev src/app.py --reload diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7d4d92 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# python_dev + +# launch +```docker compose up -d``` + +# migrations +```cd alembic/db1 && alembic revision --autogenerate && alembic upgrade head``` +```cd alembic/db2 && alembic revision --autogenerate && alembic upgrade head``` + +# что не по ТЗ +- мне не очень понравилось, что space_type и event_type сделаны через отдельные таблицы + - потому что работать с этой таблицей будет бэкенд, и у нас есть различные API хэндлеры, которым, чтобы создать запись в БД нужно + сходить на дочерние таблицы, найти нужный тип (например event_type - login), взять от него id, прийти назад и создать запись с нужным id, при этом это ещё будет не надёжным (кто-то удалит тип, поменяет название, всё поляжет) + а зачем нам отдельная таблица? (я в том смысле, что над этими типа у нас есть все операции круд, но без изменения кода бэкенда - это либо бесполезно, либо опасно) + - я заменил на более удобные Enum + diff --git a/alembic/db1/alembic.ini b/alembic/db1/alembic.ini new file mode 100644 index 0000000..3dfd6b3 --- /dev/null +++ b/alembic/db1/alembic.ini @@ -0,0 +1,115 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = . +; script_location = alembic/db2 + +# 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 +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>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# 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. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/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 = 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 + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix 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/alembic/db1/env.py b/alembic/db1/env.py new file mode 100644 index 0000000..d17b8a7 --- /dev/null +++ b/alembic/db1/env.py @@ -0,0 +1,90 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +from src.settings import settings +from src.adapters.database.models import BaseDB1 +from src.adapters.database.models.db1.post import * # noqa: F401 + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +config.set_main_option("sqlalchemy.url", settings.postgres_url_db1) + +# 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) + +target_metadata = BaseDB1.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 do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_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.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/db1/script.py.mako b/alembic/db1/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/db1/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/db2/alembic.ini b/alembic/db2/alembic.ini new file mode 100644 index 0000000..3dfd6b3 --- /dev/null +++ b/alembic/db2/alembic.ini @@ -0,0 +1,115 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = . +; script_location = alembic/db2 + +# 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 +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>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# 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. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/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 = 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 + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix 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/alembic/db2/env.py b/alembic/db2/env.py new file mode 100644 index 0000000..32c4ff2 --- /dev/null +++ b/alembic/db2/env.py @@ -0,0 +1,92 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +from src.settings import settings +from src.adapters.database.models import BaseDB2 +from src.adapters.database.models.db2.log import Log # noqa: F401 + + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +config.set_main_option("sqlalchemy.url", settings.postgres_url_db2) + +# 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) + +target_metadata = BaseDB2.metadata +print(BaseDB2.metadata, str(BaseDB2)) + +# 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 do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_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.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/db2/script.py.mako b/alembic/db2/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/db2/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/db2/versions/2025_03_13_1743-581af68b8c68_.py b/alembic/db2/versions/2025_03_13_1743-581af68b8c68_.py new file mode 100644 index 0000000..06b3b84 --- /dev/null +++ b/alembic/db2/versions/2025_03_13_1743-581af68b8c68_.py @@ -0,0 +1,37 @@ +"""empty message + +Revision ID: 581af68b8c68 +Revises: +Create Date: 2025-03-13 17:43:53.879984 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '581af68b8c68' +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('log', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('datetime', sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('space_type', sa.Enum('GLOBAL', 'BLOG', 'POST', name='spacetype'), nullable=False), + sa.Column('event_type', sa.Enum('LOGIN', 'COMMENT', 'CREATE_POST', 'DELETE_POST', 'LOGOUT', name='eventtype'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('log') + # ### end Alembic commands ### diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..838cdab --- /dev/null +++ b/compose.yaml @@ -0,0 +1,52 @@ +name: python_dev-farpost + +services: + backend: + container_name: farpost-backend + restart: always + build: . + env_file: .env + healthcheck: + test: curl -sSf http://localhost:8000/healthcheck + interval: 60s + start_period: 1s + timeout: 600s + depends_on: + database: + condition: service_healthy + restart: true + ports: + - 8000:8000 + environment: + POSTGRES_HOST: ${POSTGRES_HOST} + POSTGRES_DB1: ${POSTGRES_DB1:-db1} + POSTGRES_DB2: ${POSTGRES_DB2:-db2} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + + + database: + container_name: farpost-database + restart: always + image: postgres:latest + env_file: .env + volumes: + - ./database/create-multiple-postgresql-databases.sh:/docker-entrypoint-initdb.d/launch.sh:ro + - ./database/dumps:/docker-entrypoint-initdb.d/dumps:ro + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: pg_isready -d ${POSTGRES_DB1:-db1} + interval: 60s + start_period: 1s + timeout: 600s + ports: + - 5432:5432 + environment: + POSTGRES_MULTIPLE_DATABASES: ${POSTGRES_DB1:-db1}, ${POSTGRES_DB2:-db2} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + + +volumes: + postgres-data: + name: farpost-postgres-data diff --git a/database/create-multiple-postgresql-databases.sh b/database/create-multiple-postgresql-databases.sh new file mode 100644 index 0000000..191c35b --- /dev/null +++ b/database/create-multiple-postgresql-databases.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e +set -u + +function create_user_and_database() { + local database=$1 + echo " Creating user '$POSTGRES_USER' and database '$database'" + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + CREATE DATABASE $database; + GRANT ALL PRIVILEGES ON DATABASE $database TO $POSTGRES_USER; +EOSQL +} + +function import_dump() { + local database=$1 + psql -U "$POSTGRES_USER" -d "$database" -a -f "docker-entrypoint-initdb.d/$database.sql" +} + +if [ -n "$POSTGRES_MULTIPLE_DATABASES" ]; then + echo "Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES" + for db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr ',' ' '); do + create_user_and_database $db + # import_dump $db + done + echo "Multiple databases created" +fi \ No newline at end of file diff --git a/database/dumps/db1.sql b/database/dumps/db1.sql new file mode 100644 index 0000000..e69de29 diff --git a/database/dumps/db2.sql b/database/dumps/db2.sql new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d914616 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "python_dev" +version = "0.1.0" +description = "Add your description here" +authors = [ + { name = "matv864", email = "matv864@gmail.com" } +] +dependencies = [ + "fastapi>=0.114.0", + "sqlalchemy>=2.0.34", + "pydantic-settings>=2.4.0", + "alembic>=1.13.2", + "psycopg2-binary>=2.9.9", + "aioboto3>=13.1.1", + "asyncpg>=0.29.0", + "python-dotenv>=1.0.1", + "fastadmin>=0.2.13", + "bcrypt>=4.2.0", + "passlib>=1.7.4", + "pydantic[email]>=2.9.2", + "beautifulsoup4>=4.12.3", + "granian[reload]>=1.7.6" +] +readme = "README.md" +requires-python = ">= 3.12" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.rye] +managed = true +dev-dependencies = [ + "ruff>=0.6.4", + "mypy>=1.11.2" +] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["src/"] + diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..d53c03f --- /dev/null +++ b/requirements.lock @@ -0,0 +1,128 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: false +# with-sources: false +# generate-hashes: false +# universal: false + +-e file:. +aioboto3==13.1.1 + # via python-dev +aiobotocore==2.13.1 + # via aioboto3 +aiofiles==24.1.0 + # via aioboto3 +aiohappyeyeballs==2.4.0 + # via aiohttp +aiohttp==3.10.5 + # via aiobotocore +aioitertools==0.12.0 + # via aiobotocore +aiosignal==1.3.1 + # via aiohttp +alembic==1.13.2 + # via python-dev +annotated-types==0.7.0 + # via pydantic +anyio==4.4.0 + # via starlette + # via watchfiles +asgiref==3.8.1 + # via fastadmin +asyncpg==0.29.0 + # via python-dev +attrs==24.2.0 + # via aiohttp +bcrypt==4.2.0 + # via python-dev +beautifulsoup4==4.12.3 + # via python-dev +boto3==1.34.131 + # via aiobotocore +botocore==1.34.131 + # via aiobotocore + # via boto3 + # via s3transfer +click==8.1.7 + # via granian +colorama==0.4.6 + # via click +dnspython==2.7.0 + # via email-validator +email-validator==2.2.0 + # via pydantic +fastadmin==0.2.13 + # via python-dev +fastapi==0.114.1 + # via python-dev +frozenlist==1.4.1 + # via aiohttp + # via aiosignal +granian==1.7.6 + # via python-dev +greenlet==3.1.0 + # via sqlalchemy +idna==3.8 + # via anyio + # via email-validator + # via yarl +jmespath==1.0.1 + # via boto3 + # via botocore +mako==1.3.5 + # via alembic +markupsafe==2.1.5 + # via mako +multidict==6.1.0 + # via aiohttp + # via yarl +passlib==1.7.4 + # via python-dev +psycopg2-binary==2.9.9 + # via python-dev +pydantic==2.9.2 + # via fastapi + # via pydantic-settings + # via python-dev +pydantic-core==2.23.4 + # via pydantic +pydantic-settings==2.5.2 + # via python-dev +pyjwt==2.9.0 + # via fastadmin +python-dateutil==2.9.0.post0 + # via botocore +python-dotenv==1.0.1 + # via pydantic-settings + # via python-dev +s3transfer==0.10.2 + # via boto3 +six==1.16.0 + # via python-dateutil +sniffio==1.3.1 + # via anyio +soupsieve==2.6 + # via beautifulsoup4 +sqlalchemy==2.0.34 + # via alembic + # via python-dev +starlette==0.38.5 + # via fastapi +typing-extensions==4.12.2 + # via alembic + # via fastapi + # via pydantic + # via pydantic-core + # via sqlalchemy +urllib3==2.2.3 + # via botocore +watchfiles==1.0.4 + # via granian +wrapt==1.16.0 + # via aiobotocore +yarl==1.11.1 + # via aiohttp diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/adapters/database/models/__init__.py b/src/adapters/database/models/__init__.py new file mode 100644 index 0000000..59af7b3 --- /dev/null +++ b/src/adapters/database/models/__init__.py @@ -0,0 +1,2 @@ +from .db1.base import Base as BaseDB1 +from .db2.base import Base as BaseDB2 diff --git a/src/adapters/database/models/common.py b/src/adapters/database/models/common.py new file mode 100644 index 0000000..d006a96 --- /dev/null +++ b/src/adapters/database/models/common.py @@ -0,0 +1,17 @@ +from enum import Enum +from datetime import datetime, timezone + +def utc_signed_now(): + return datetime.now(timezone.utc) + +class SpaceType(Enum): + GLOBAL=0 + BLOG=1 + POST=2 + +class EventType(Enum): + LOGIN=0 + COMMENT=1 + CREATE_POST=2 + DELETE_POST=3 + LOGOUT=4 diff --git a/src/adapters/database/models/db1/base.py b/src/adapters/database/models/db1/base.py new file mode 100644 index 0000000..fa13a24 --- /dev/null +++ b/src/adapters/database/models/db1/base.py @@ -0,0 +1,10 @@ +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(AsyncAttrs, DeclarativeBase): + """ + Base class that provides metadata and id with int4 + """ + + id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True) diff --git a/src/adapters/database/models/db1/blog.py b/src/adapters/database/models/db1/blog.py new file mode 100644 index 0000000..e69de29 diff --git a/src/adapters/database/models/db1/post.py b/src/adapters/database/models/db1/post.py new file mode 100644 index 0000000..e69de29 diff --git a/src/adapters/database/models/db1/user.py b/src/adapters/database/models/db1/user.py new file mode 100644 index 0000000..e69de29 diff --git a/src/adapters/database/models/db2/base.py b/src/adapters/database/models/db2/base.py new file mode 100644 index 0000000..d5a4c0b --- /dev/null +++ b/src/adapters/database/models/db2/base.py @@ -0,0 +1,11 @@ +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(AsyncAttrs, DeclarativeBase): + """ + Base class that provides metadata and id with int4 + """ + + id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True) + diff --git a/src/adapters/database/models/db2/log.py b/src/adapters/database/models/db2/log.py new file mode 100644 index 0000000..e20cc09 --- /dev/null +++ b/src/adapters/database/models/db2/log.py @@ -0,0 +1,16 @@ +from datetime import datetime + +from sqlalchemy import INTEGER, TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from ..common import utc_signed_now, SpaceType, EventType + + +class Log(Base): + __tablename__ = "log" + id: Mapped[int] = mapped_column(INTEGER, primary_key=True, autoincrement=True) + date_time: Mapped[datetime] = mapped_column("datetime", TIMESTAMP(timezone=True), default=utc_signed_now) + user_id: Mapped[int] + space_type: Mapped[SpaceType] + event_type: Mapped[EventType] diff --git a/src/adapters/database/repositories.py b/src/adapters/database/repositories.py new file mode 100644 index 0000000..9718cd4 --- /dev/null +++ b/src/adapters/database/repositories.py @@ -0,0 +1,23 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional +from uuid import UUID + +from sqlalchemy import asc, desc, func, select +from sqlalchemy.sql.expression import nulls_last +from sqlalchemy.sql.selectable import Select + +from src.settings import settings +from src.utils.exceptions import ( + RefreshClientInfoIncorrect, + RefreshException, + RefreshExpired, +) +from src.utils.repository import ( + SQLAlchemyRepository, + _sentinel, +) + +# from .models import ( +# ) + + diff --git a/src/adapters/database/repository_gateway.py b/src/adapters/database/repository_gateway.py new file mode 100644 index 0000000..e8afcb3 --- /dev/null +++ b/src/adapters/database/repository_gateway.py @@ -0,0 +1,10 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +# from src.adapters.database.repositories import ( + +# ) + + +class RepositoriesGateway: + def __init__(self, session: AsyncSession): + pass diff --git a/src/adapters/database/session.py b/src/adapters/database/session.py new file mode 100644 index 0000000..af9d72e --- /dev/null +++ b/src/adapters/database/session.py @@ -0,0 +1,8 @@ +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from src.settings import settings + +engine_db1 = create_async_engine(settings.postgres_url_db1) +engine_db2 = create_async_engine(settings.postgres_url_db2) +async_session_maker_db1 = async_sessionmaker(engine_db1, expire_on_commit=False) +async_session_maker_db2 = async_sessionmaker(engine_db2, expire_on_commit=False) diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..6ba29b9 --- /dev/null +++ b/src/api/__init__.py @@ -0,0 +1 @@ +from .healthcheck import healthcheck_router as healthcheck_router \ No newline at end of file diff --git a/src/api/healthcheck.py b/src/api/healthcheck.py new file mode 100644 index 0000000..5358ef3 --- /dev/null +++ b/src/api/healthcheck.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter, status + +healthcheck_router = APIRouter() + + +@healthcheck_router.get("/healthcheck", status_code=status.HTTP_200_OK) +async def healthcheck(): + return None diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..edac3dc --- /dev/null +++ b/src/app.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from src.api import healthcheck_router + +app = FastAPI( + title="python_dev-backend", +) + + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(healthcheck_router) + +main_app_router = APIRouter(prefix="/api") + +app.include_router(main_app_router) + diff --git a/src/settings.py b/src/settings.py new file mode 100644 index 0000000..f46ba42 --- /dev/null +++ b/src/settings.py @@ -0,0 +1,32 @@ +from functools import cached_property + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + POSTGRES_HOST: str + POSTGRES_DB1: str = "db1" + POSTGRES_DB2: str = "db2" + POSTGRES_USER: str + POSTGRES_PASSWORD: str + + @cached_property + def postgres_url_db1(self): + return ( + "postgresql+asyncpg://" + + f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@" + + f"{self.POSTGRES_HOST}/{self.POSTGRES_DB1}" + ) + + @cached_property + def postgres_url_db2(self): + return ( + "postgresql+asyncpg://" + + f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@" + + f"{self.POSTGRES_HOST}/{self.POSTGRES_DB2}" + ) + + +settings = Settings() # type: ignore diff --git a/src/unit_of_work.py b/src/unit_of_work.py new file mode 100644 index 0000000..99f4d4b --- /dev/null +++ b/src/unit_of_work.py @@ -0,0 +1,44 @@ +from asyncio import shield + +from src.adapters.database.repository_gateway import ( + RepositoriesGateway, + RepositoriesGatewayProtocol, +) +from src.adapters.database.session import async_session_maker +from src.adapters.filestorage.repository import ( + FileStorageProtocol, + FileStorageRepository, +) +from src.adapters.filestorage.session import s3_session_factory +from src.adapters.verification import VerificationGateway, VerificationProtocol +from src.utils.unit_of_work import UnitOfWorkProtocol + + +class UnitOfWork(UnitOfWorkProtocol): + file_storage: FileStorageProtocol + repositories: RepositoriesGatewayProtocol + verifications: VerificationProtocol + + def __init__(self): + self.db_session_factory = async_session_maker + self.s3_session_facotry = s3_session_factory + + async def __aenter__(self): + self.db_session = self.db_session_factory() + self.s3_session = self.s3_session_facotry() + + self.file_storage = FileStorageRepository(self.s3_session) + self.repositories = RepositoriesGateway(self.db_session) + self.verifications = VerificationGateway() + + return self + + async def __aexit__(self, *args): + await self.rollback() + await shield(self.db_session.close()) + + async def commit(self): + await self.db_session.commit() + + async def rollback(self): + await self.db_session.rollback() diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/dependencies.py b/src/utils/dependencies.py new file mode 100644 index 0000000..4846f82 --- /dev/null +++ b/src/utils/dependencies.py @@ -0,0 +1,20 @@ +from datetime import datetime, timezone +from typing import Annotated + +from fastapi import Depends +from fastapi.security import OAuth2PasswordBearer + +from src.adapters.database.models.clients import ClientType +from src.adapters.jwt_token import JwtToken + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="authenticaition", auto_error=False) + + +async def provide_jwt_token( + encoded_token: Annotated[str | None, Depends(oauth2_scheme)] = None, +) -> JwtToken: + if encoded_token is None: + return JwtToken( + exp=datetime.now(timezone.utc), client_id=0, client_type=ClientType.individ + ) + return JwtToken.decode(encoded_token) diff --git a/src/utils/exceptions.py b/src/utils/exceptions.py new file mode 100644 index 0000000..ec143ba --- /dev/null +++ b/src/utils/exceptions.py @@ -0,0 +1,52 @@ +class RepositoryException(Exception): ... + + +class AccessDenied(Exception): ... + + +class ResultNotFound(RepositoryException): ... + + +class AuthorizationException(Exception): ... + + +class WrongCredentials(RepositoryException): ... + + +class ForeignKeyError(RepositoryException): ... + + +class JwtException(AuthorizationException): ... + + +class JwtExpired(JwtException): ... + + +class JwtInvalid(JwtException): ... + + +class RefreshException(AuthorizationException): ... + + +class RefreshExpired(RefreshException): ... + + +class RefreshInvalid(RefreshException): ... + + +class VerificationException(Exception): ... + + +class RefreshClientInfoIncorrect(RefreshException): ... + + +class FileNotFound(Exception): ... + + +class UserNotRegistered(Exception): ... + + +class PlaceOrderForeignKeyError(Exception): ... + + +class UserAlreadyExist(Exception): ... diff --git a/src/utils/repository.py b/src/utils/repository.py new file mode 100644 index 0000000..acb3f6d --- /dev/null +++ b/src/utils/repository.py @@ -0,0 +1,143 @@ +from abc import abstractmethod +from typing import Any, Optional, Protocol + +from sqlalchemy import func, insert, select, update +from sqlalchemy.exc import IntegrityError, NoResultFound +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql.base import ExecutableOption + +from src.utils.exceptions import ForeignKeyError, ResultNotFound + +_sentinel: Any = object() + + +class AbstractRepository(Protocol): + @abstractmethod + async def add_one(self, **data): + raise NotImplementedError + + +class SQLAlchemyRepository(AbstractRepository): + model = _sentinel + + def __init__(self, session: AsyncSession): + self.session = session + + async def add_one(self, **data): + stmt = insert(self.model).values(**data).returning(self.model) + try: + res = await self.session.execute(stmt) + return res.scalar_one() + except IntegrityError: + raise ForeignKeyError + + async def edit_one(self, id: int, **data): + stmt = update(self.model).values(**data).filter_by(id=id).returning(self.model) + try: + res = await self.session.execute(stmt) + return res.unique().scalar_one() + except NoResultFound: + raise ResultNotFound + except IntegrityError: + raise ForeignKeyError + + async def find_all(self): + stmt = select(self.model).options(*self.get_select_options()) + res = await self.session.execute(stmt) + return res.unique().scalars().fetchall() + + async def find_filtered(self, sort_by: str = "", **filter_by): + stmt = ( + select(self.model) + .options(*self.get_select_options()) + .filter_by(**filter_by) + .order_by(getattr(self.model, sort_by, None)) + ) + res = await self.session.execute(stmt) + return res.unique().scalars().fetchall() + + async def find_filtered_and_paginated(self, page: int, limit: int, **filter_by): + stmt = ( + select(self.model) + .options(*self.get_select_options()) + .filter_by(**filter_by) + .offset((page - 1) * limit) + .limit(limit) + ) + + res = await self.session.execute(stmt) + return res.unique().scalars().fetchall() + + async def find_one(self, **filter_by): + stmt = ( + select(self.model) + .options(*self.get_select_options()) + .filter_by(**filter_by) + ) + res = await self.session.execute(stmt) + try: + return res.scalar_one() + except NoResultFound: + raise ResultNotFound + + async def count_filtered(self, **filter_by): + stmt = ( + select(func.count()) + .select_from(self.model) + .options(*self.get_select_options()) + .filter_by(**filter_by) + ) + res = await self.session.execute(stmt) + return res.unique().scalar_one() + + async def find_filtered_in(self, column_name: str, values: list): + stmt = ( + select(self.model) + .options(*self.get_select_options()) + .filter(getattr(self.model, column_name).in_(values)) + ) + res = await self.session.execute(stmt) + return res.unique().scalars().fetchall() + + # this find operation in delete methods help us to raise 404 error instead of 50x + async def delete_one(self, id: int) -> None: + await self.session.delete((await self.find_one(id=id))) + + async def delete_filtered(self, **filter_by) -> None: + for cart_item in await self.find_filtered(**filter_by): + await self.session.delete(cart_item) + + def get_select_options(self) -> list[ExecutableOption]: + return [] + + async def count_filtered_by_fastadmin( + self, + joins: list[Any], + filters: list[Any], + ): + stmt = select(func.count()).select_from(self.model).filter(*filters) + for join in joins: + stmt = stmt.join(join) + + res = await self.session.execute(stmt) + return res.unique().scalar_one() + + async def find_filtered_by_fastadmin( + self, + options: list[Any], + joins: list[Any], + filters: list[Any], + sort_by: Optional[Any], + offset: int, + limit: int, + ): + stmt = select(self.model).filter(*filters).offset(offset).limit(limit) + if sort_by is not None: + stmt = stmt.order_by(sort_by) + for join in joins: + stmt = stmt.join(join) + stmt = stmt.options(*options) + + res = await self.session.execute(stmt) + return res.scalars() + diff --git a/src/utils/time.py b/src/utils/time.py new file mode 100644 index 0000000..1713398 --- /dev/null +++ b/src/utils/time.py @@ -0,0 +1,5 @@ +from datetime import datetime, timezone + + +def utc_signed_now(): + return datetime.now(timezone.utc) diff --git a/src/utils/validating_base64.py b/src/utils/validating_base64.py new file mode 100644 index 0000000..e1abcc2 --- /dev/null +++ b/src/utils/validating_base64.py @@ -0,0 +1,13 @@ +import base64 +import binascii + + +def is_valid_base64(value: str) -> bool: + try: + value = value.split(";base64,")[1] + base64.decodebytes(value.encode("ascii")) + return True + except binascii.Error: + return False + except IndexError: + return False diff --git a/src/utils/verification.py b/src/utils/verification.py new file mode 100644 index 0000000..52d157a --- /dev/null +++ b/src/utils/verification.py @@ -0,0 +1,9 @@ +from abc import abstractmethod +from typing import Protocol + + +class VerificationProtocol(Protocol): + @abstractmethod + async def send_verification_code(self, phone_number: str) -> int: + """Sends verification code to user and returns sended code""" + raise NotImplementedError