From 22ec206c6d610c5a8d2bb05990bb5c451390d283 Mon Sep 17 00:00:00 2001 From: Gray Advantage Date: Mon, 14 Apr 2025 15:32:33 +0300 Subject: [PATCH 1/6] feat: add alembic + first test migrations --- Makefile | 6 ++ alembic.ini | 51 +++++++++++++ app/app/app.py | 8 +- app/core/config.py | 38 +++++++--- app/core/database/migrations/__init__.py | 0 app/core/database/migrations/env.py | 75 +++++++++++++++++++ app/core/database/migrations/script.py.mako | 28 +++++++ .../versions/2025_04_14_1524-32d5607c5cc0_.py | 42 +++++++++++ app/core/database/mixins.py | 6 ++ app/users/__init__.py | 0 app/users/models.py | 19 +++++ docker-compose.yml | 17 +++++ main.py | 4 +- pyproject.toml | 2 + 14 files changed, 276 insertions(+), 20 deletions(-) create mode 100644 alembic.ini create mode 100644 app/core/database/migrations/__init__.py create mode 100644 app/core/database/migrations/env.py create mode 100644 app/core/database/migrations/script.py.mako create mode 100644 app/core/database/migrations/versions/2025_04_14_1524-32d5607c5cc0_.py create mode 100644 app/core/database/mixins.py create mode 100644 app/users/__init__.py create mode 100644 app/users/models.py create mode 100644 docker-compose.yml diff --git a/Makefile b/Makefile index e1769cb..e34da52 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,12 @@ run: python main.py +migrations: + alembic revision --autogenerate + +migrate: + alembic upgrade head + ruff: ruff check . diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..9ebafb9 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,51 @@ +[alembic] +script_location = .\app\core\database\migrations +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +prepend_sys_path = . +version_path_separator = os + +[post_write_hooks] +hooks = ruff_format, ruff_fix + +ruff_format.type = exec +ruff_format.executable = ruff +ruff_format.options = format REVISION_SCRIPT_FILENAME + +ruff_fix.type = exec +ruff_fix.executable = ruff +ruff_fix.options = check --fix REVISION_SCRIPT_FILENAME + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +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/app/app/app.py b/app/app/app.py index 5dbbee0..f0111c4 100644 --- a/app/app/app.py +++ b/app/app/app.py @@ -1,13 +1,9 @@ -from pathlib import Path - from aiohttp.web import Application as AiohttpApplication from app.core.config import Config, setup_config from app.core.database.database import Database, setup_database from app.core.store import Store, setup_store -__all__ = ("Application", "setup_app") - class Application(AiohttpApplication): database: Database | None = None @@ -18,8 +14,8 @@ class Application(AiohttpApplication): app = Application() -def setup_app(config_path: Path) -> Application: - setup_config(app, config_path) +def setup_app() -> Application: + setup_config(app) setup_database(app) setup_store(app) return app diff --git a/app/core/config.py b/app/core/config.py index 0944455..4f4e8c4 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,30 +1,46 @@ import typing -from pathlib import Path +from functools import cached_property -from pydantic import BaseModel, Field +from pydantic import BaseModel from pydantic_settings import BaseSettings, SettingsConfigDict +from sqlalchemy.engine.url import URL if typing.TYPE_CHECKING: from app.app.app import Application class BotConfig(BaseModel): - token: str = Field("...", validation_alias="BOT__TOKEN") + token: str = "..." class DatabaseConfig(BaseModel): - host: str = Field("localhost", validation_alias="DATABASE__HOST") - port: int = Field(5432, validation_alias="DATABASE__PORT") - user: str = Field("postgres", validation_alias="DATABASE__USER") - password: str = Field("postgres", validation_alias="DATABASE__PASSWORD") - database: str = Field("project", validation_alias="DATABASE__DATABASE") + host: str = "localhost" + port: int = 5432 + user: str = "postgres" + password: str = "postgres" + database: str = "project" + + @cached_property + def url(self) -> URL: + return URL.create( + drivername="postgresql+asyncpg", + username=self.user, + password=self.password, + host=self.host, + port=self.port, + database=self.database, + ) class Config(BaseSettings): bot: BotConfig | None = None database: DatabaseConfig | None = None - model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter='__') + model_config = SettingsConfigDict( + env_file=(".env", "../../../../.env"), # Если main / если миграции + env_nested_delimiter="__", + ) -def setup_config(app: "Application", config_path: Path) -> None: - app.config = Config(_env_file=config_path) + +def setup_config(app: "Application") -> None: + app.config = Config() diff --git a/app/core/database/migrations/__init__.py b/app/core/database/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/database/migrations/env.py b/app/core/database/migrations/env.py new file mode 100644 index 0000000..5ea4c5c --- /dev/null +++ b/app/core/database/migrations/env.py @@ -0,0 +1,75 @@ +import asyncio +import importlib +import pkgutil +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +import app +from app.app.app import app as application, setup_app +from app.core.database.sqlalchemy_base import BaseModel + +setup_app() +config = context.config + +for module_info in pkgutil.walk_packages(app.__path__, prefix=app.__name__ + "."): + importlib.import_module(module_info.name) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +if application.config is None or application.config.database is None: + raise ValueError("No configuration file provided") + +config.set_main_option( + "sqlalchemy.url", + application.config.database.url.render_as_string(hide_password=False), +) + +target_metadata = BaseModel.metadata + + +def run_migrations_offline() -> None: + 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: + 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: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/app/core/database/migrations/script.py.mako b/app/core/database/migrations/script.py.mako new file mode 100644 index 0000000..480b130 --- /dev/null +++ b/app/core/database/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${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: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/app/core/database/migrations/versions/2025_04_14_1524-32d5607c5cc0_.py b/app/core/database/migrations/versions/2025_04_14_1524-32d5607c5cc0_.py new file mode 100644 index 0000000..6741097 --- /dev/null +++ b/app/core/database/migrations/versions/2025_04_14_1524-32d5607c5cc0_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 32d5607c5cc0 +Revises: +Create Date: 2025-04-14 15:24:41.833280 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "32d5607c5cc0" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "telegram_user", + sa.Column("username", sa.String(length=64), nullable=False), + sa.Column("score", sa.Integer(), nullable=False), + sa.Column("win_count", sa.Integer(), nullable=False), + sa.Column("loss_count", sa.Integer(), nullable=False), + sa.Column("id", sa.BigInteger(), nullable=False), + sa.CheckConstraint("loss_count >= 0", name="loss_count_non_negative"), + sa.CheckConstraint("win_count >= 0", name="win_count_non_negative"), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("telegram_user") + # ### end Alembic commands ### diff --git a/app/core/database/mixins.py b/app/core/database/mixins.py new file mode 100644 index 0000000..a7afaae --- /dev/null +++ b/app/core/database/mixins.py @@ -0,0 +1,6 @@ +from sqlalchemy import BigInteger +from sqlalchemy.orm import Mapped, mapped_column + + +class IDMixin: + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) diff --git a/app/users/__init__.py b/app/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/users/models.py b/app/users/models.py new file mode 100644 index 0000000..c716161 --- /dev/null +++ b/app/users/models.py @@ -0,0 +1,19 @@ +from sqlalchemy import CheckConstraint, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database.mixins import IDMixin +from app.core.database.sqlalchemy_base import BaseModel + + +class TelegramUserModel(IDMixin, BaseModel): + __tablename__ = "telegram_user" + + __table_args__ = ( + CheckConstraint("win_count >= 0", name="win_count_non_negative"), + CheckConstraint("loss_count >= 0", name="loss_count_non_negative"), + ) + + username: Mapped[str] = mapped_column(String(64)) + score: Mapped[int] = mapped_column(default=0) + win_count: Mapped[int] = mapped_column(default=0) + loss_count: Mapped[int] = mapped_column(default=0) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8e9069b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + postgres: + image: postgres:17.4-alpine + ports: + - ${DATABASE__PORT}:${DATABASE__PORT} + env_file: + - .env + environment: + - POSTGRES_USER=${DATABASE__USER} + - POSTGRES_PASSWORD=${DATABASE__PASSWORD} + - POSTGRES_DB=${DATABASE__DATABASE} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 2s + timeout: 5s + retries: 10 + command: -p ${DATABASE__PORT} diff --git a/main.py b/main.py index 198ee7b..942071c 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,6 @@ -from pathlib import Path - from aiohttp.web import run_app from app.app.app import setup_app if __name__ == "__main__": - run_app(setup_app(Path(__file__).resolve().parent / ".env")) + run_app(setup_app()) diff --git a/pyproject.toml b/pyproject.toml index 2e0c115..8d0ba36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,7 @@ extend-select = [ # По мере "бесячих" ошибок буду дополнять, а так, всё строго extend-ignore = [ "D1", + "CPY001", # По рекомендации https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules "W191", "E111", @@ -117,6 +118,7 @@ extend-ignore = [ "urls.py" = ["PLC0415"] "store.py" = ["PLC0415"] "tests/*.py" = ["SIM300", "F403", "F405", "INP001"] +"*/versions/*.py" = ["D415", "INP001"] [tool.ruff.lint.pydocstyle] From 399e0804d9d9ed9ee4defe1a40d1ee58500d64c8 Mon Sep 17 00:00:00 2001 From: Gray Advantage Date: Tue, 15 Apr 2025 13:52:10 +0300 Subject: [PATCH 2/6] feat: add poller and rabbitmq queue containers --- app/{app => }/app.py | 6 ++-- app/core/config.py | 23 +++++++++++--- app/core/database/database.py | 2 +- app/core/database/migrations/env.py | 2 +- app/core/manager.py | 39 ++++++++++++++++++++++++ app/core/store.py | 2 +- app/poller/Dockerfile | 13 ++++++++ app/{app => poller}/__init__.py | 0 app/poller/poller.py | 34 +++++++++++++++++++++ docker-compose.yml | 31 +++++++++++++++++++ main.py | 2 +- pyproject.toml | 6 ++-- requirements.txt | 1 + template.env | 12 ++++++++ uv.lock | 47 +++++++++++++++++++++++++++++ 15 files changed, 207 insertions(+), 13 deletions(-) rename app/{app => }/app.py (79%) create mode 100644 app/core/manager.py create mode 100644 app/poller/Dockerfile rename app/{app => poller}/__init__.py (100%) create mode 100644 app/poller/poller.py create mode 100644 template.env diff --git a/app/app/app.py b/app/app.py similarity index 79% rename from app/app/app.py rename to app/app.py index f0111c4..c09fdd8 100644 --- a/app/app/app.py +++ b/app/app.py @@ -6,9 +6,9 @@ class Application(AiohttpApplication): - database: Database | None = None - config: Config | None = None - store: Store | None = None + database: Database + config: Config + store: Store app = Application() diff --git a/app/core/config.py b/app/core/config.py index 4f4e8c4..b86576c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -6,7 +6,7 @@ from sqlalchemy.engine.url import URL if typing.TYPE_CHECKING: - from app.app.app import Application + from app.app import Application class BotConfig(BaseModel): @@ -32,12 +32,27 @@ def url(self) -> URL: ) +class RabbitmqConfig(BaseModel): + host: str = "localhost" + port: int = 5672 + user: str = "guest" + password: str = "guest" + input_queue: str = "input_queue" + output_queue: str = "output_queue" + + @cached_property + def url(self) -> str: + return f"amqp://{self.user}:{self.password}@{self.host}:{self.port}" + + class Config(BaseSettings): - bot: BotConfig | None = None - database: DatabaseConfig | None = None + bot: BotConfig + database: DatabaseConfig + rabbitmq: RabbitmqConfig model_config = SettingsConfigDict( - env_file=(".env", "../../../../.env"), # Если main / если миграции + # Если main / если poller / если миграции + env_file=(".env", "../../.env", "../../../../.env"), env_nested_delimiter="__", ) diff --git a/app/core/database/database.py b/app/core/database/database.py index a837876..897bdb0 100644 --- a/app/core/database/database.py +++ b/app/core/database/database.py @@ -12,7 +12,7 @@ from app.core.database.sqlalchemy_base import BaseModel if TYPE_CHECKING: - from app.app.app import Application + from app.app import Application class Database: diff --git a/app/core/database/migrations/env.py b/app/core/database/migrations/env.py index 5ea4c5c..d9ecf29 100644 --- a/app/core/database/migrations/env.py +++ b/app/core/database/migrations/env.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config import app -from app.app.app import app as application, setup_app +from app.app import app as application, setup_app from app.core.database.sqlalchemy_base import BaseModel setup_app() diff --git a/app/core/manager.py b/app/core/manager.py new file mode 100644 index 0000000..b1ad7e7 --- /dev/null +++ b/app/core/manager.py @@ -0,0 +1,39 @@ +from collections.abc import Awaitable, Callable + +import aio_pika +from aio_pika import Message +from aio_pika.abc import AbstractIncomingMessage + + +class RabbitMQManager: + def __init__(self, amqp_url: str, queue_name: str): + self._url = amqp_url + self._queue_name = queue_name + self._connection: aio_pika.abc.AbstractRobustConnection | None = None + self._channel: aio_pika.abc.AbstractChannel | None = None + self._queue: aio_pika.abc.AbstractQueue | None = None + + async def connect(self) -> None: + self._connection = await aio_pika.connect_robust(self._url) + self._channel = await self._connection.channel() + self._queue = await self._channel.declare_queue(self._queue_name, durable=True) + + async def send(self, body: bytes) -> None: + if self._channel is None: + raise RuntimeError("RabbitMQManager is not connected") + await self._channel.default_exchange.publish( + Message(body=body), + routing_key=self._queue_name, + ) + + async def consume( + self, + handler: Callable[[AbstractIncomingMessage], Awaitable[None]], + ) -> None: + if self._queue is None: + raise RuntimeError("RabbitMQManager is not connected") + await self._queue.consume(handler) + + async def close(self) -> None: + if self._connection: + await self._connection.close() diff --git a/app/core/store.py b/app/core/store.py index 7f21968..62350ca 100644 --- a/app/core/store.py +++ b/app/core/store.py @@ -1,7 +1,7 @@ import typing if typing.TYPE_CHECKING: - from app.app.app import Application + from app.app import Application class Store: # noqa: B903 diff --git a/app/poller/Dockerfile b/app/poller/Dockerfile new file mode 100644 index 0000000..dfd71b2 --- /dev/null +++ b/app/poller/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-alpine +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /project +COPY pyproject.toml uv.lock ./ + +RUN uv sync --compile-bytecode --no-cache --no-dev +ENV PATH="/project/.venv/bin:$PATH" + +COPY . . + +WORKDIR /project +CMD python -m app.poller.poller diff --git a/app/app/__init__.py b/app/poller/__init__.py similarity index 100% rename from app/app/__init__.py rename to app/poller/__init__.py diff --git a/app/poller/poller.py b/app/poller/poller.py new file mode 100644 index 0000000..e0a1b8f --- /dev/null +++ b/app/poller/poller.py @@ -0,0 +1,34 @@ +import asyncio +import json +from typing import Any, cast + +import aiohttp + +from app.app import app, setup_app +from app.core.manager import RabbitMQManager + + +async def get_updates(offset: int) -> dict[str, Any]: + url = f"https://api.telegram.org/bot{app.config.bot.token}/getUpdates" # поменяю + async with aiohttp.ClientSession() as session: + async with session.get(url, params={"offset": offset, "timeout": 30}) as resp: + return cast(dict[str, Any], await resp.json()) + + +async def poll_and_push() -> None: + rabbit = RabbitMQManager( + amqp_url=app.config.rabbitmq.url, + queue_name=app.config.rabbitmq.input_queue, + ) + await rabbit.connect() + + offset = 0 + while True: + data = await get_updates(offset) + for update in data.get("result", []): + offset = update["update_id"] + 1 + await rabbit.send(json.dumps(update).encode()) + + +setup_app() +asyncio.run(poll_and_push()) diff --git a/docker-compose.yml b/docker-compose.yml index 8e9069b..b95c045 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,4 +14,35 @@ services: interval: 2s timeout: 5s retries: 10 + restart: always command: -p ${DATABASE__PORT} + + rabbitmq: + image: rabbitmq:4.0.8-management-alpine # временно + ports: + - ${RABBITMQ__PORT}:${RABBITMQ__PORT} + - 15672:15672 # временно + env_file: + - .env + environment: + - RABBITMQ_DEFAULT_USER=${RABBITMQ__USER} + - RABBITMQ_DEFAULT_PASS=${RABBITMQ__PASSWORD} + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "ping"] + interval: 2s + timeout: 5s + retries: 10 + restart: always + + poller: + build: + context: . + dockerfile: app/poller/Dockerfile + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + env_file: + - .env + restart: always diff --git a/main.py b/main.py index 942071c..0688cf5 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,6 @@ from aiohttp.web import run_app -from app.app.app import setup_app +from app.app import setup_app if __name__ == "__main__": run_app(setup_app()) diff --git a/pyproject.toml b/pyproject.toml index 8d0ba36..7a093ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ dependencies = [ "pycparser==2.22", "typing_extensions==4.13.2", "webargs==8.6.0", - "yarl==1.19.0" + "yarl==1.19.0", + "aio-pika==9.5.5", ] [dependency-groups] @@ -95,6 +96,7 @@ extend-select = [ extend-ignore = [ "D1", "CPY001", + "SIM117", # По рекомендации https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules "W191", "E111", @@ -138,4 +140,4 @@ mark-parentheses = false [tool.mypy] strict = true # Ещё строже! python_version = "3.12" -plugins = ['pydantic.mypy'] \ No newline at end of file +plugins = ['pydantic.mypy'] diff --git a/requirements.txt b/requirements.txt index db5b867..ff3eb4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ aiohttp==3.11.16 aiohttp_apispec==3.0.0b2 aiohttp_cors==0.8.1 aiohttp_session==2.12.1 +aio-pika==9.5.5 alembic==1.15.2 asyncpg==0.30.0 cryptography==44.0.1 diff --git a/template.env b/template.env new file mode 100644 index 0000000..52b2316 --- /dev/null +++ b/template.env @@ -0,0 +1,12 @@ +BOT__TOKEN=0123456789:a1b2c3d4e5f6g7h8i9jklmnopqrstuvwxyz + +DATABASE__HOST=localhost +DATABASE__PORT=5432 +DATABASE__USER=postgres +DATABASE__PASSWORD=postgres +DATABASE__DATABASE=postgres + +RABBITMQ__HOST=localhost +RABBITMQ__PORT=5672 +RABBITMQ__USER=guest +RABBITMQ__PASSWORD=guest diff --git a/uv.lock b/uv.lock index f7551c0..0e86abb 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,20 @@ version = 1 revision = 1 requires-python = "==3.12.*" +[[package]] +name = "aio-pika" +version = "9.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiormq" }, + { name = "exceptiongroup" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/00/5391405f15e85bd6cb859186dbe04d99186ca29410a7cdc52848b55a1d72/aio_pika-9.5.5.tar.gz", hash = "sha256:3d2f25838860fa7e209e21fc95555f558401f9b49a832897419489f1c9e1d6a4", size = 48468 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cf/efa5581760bd08263bce8dbf943f32006b6dfd5bc120f43a26257281b546/aio_pika-9.5.5-py3-none-any.whl", hash = "sha256:94e0ac3666398d6a28b0c3b530c1febf4c6d4ececb345620727cfd7bfe1c02e0", size = 54257 }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -80,6 +94,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/81/a9ff9032bbe7632fce8812487efe32cee3c76bc0b3221561cd5b6954d876/aiohttp_session-2.12.1-py3-none-any.whl", hash = "sha256:654df46c3c9b73294312795f558c3bca4a85bfd3b01a8b744d984ae3958dce5f", size = 12464 }, ] +[[package]] +name = "aiormq" +version = "6.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pamqp" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/79/5397756a8782bf3d0dce392b48260c3ec81010f16bef8441ff03505dccb4/aiormq-6.8.1.tar.gz", hash = "sha256:a964ab09634be1da1f9298ce225b310859763d5cf83ef3a7eae1a6dc6bd1da1a", size = 30528 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/be/1a613ae1564426f86650ff58c351902895aa969f7e537e74bfd568f5c8bf/aiormq-6.8.1-py3-none-any.whl", hash = "sha256:5da896c8624193708f9409ffad0b20395010e2747f22aa4150593837f40aa017", size = 31174 }, +] + [[package]] name = "aiosignal" version = "1.3.2" @@ -236,6 +263,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, ] +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + [[package]] name = "filelock" version = "3.18.0" @@ -318,6 +354,7 @@ name = "jeopardybot" version = "0.0.1" source = { virtual = "." } dependencies = [ + { name = "aio-pika" }, { name = "aiohttp" }, { name = "aiohttp-apispec" }, { name = "aiohttp-cors" }, @@ -358,6 +395,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aio-pika", specifier = "==9.5.5" }, { name = "aiohttp", specifier = "==3.11.16" }, { name = "aiohttp-apispec", specifier = "==3.0.0b2" }, { name = "aiohttp-cors", specifier = "==0.8.1" }, @@ -522,6 +560,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] +[[package]] +name = "pamqp" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/62/35bbd3d3021e008606cd0a9532db7850c65741bbf69ac8a3a0d8cfeb7934/pamqp-3.3.0.tar.gz", hash = "sha256:40b8795bd4efcf2b0f8821c1de83d12ca16d5760f4507836267fd7a02b06763b", size = 30993 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/8d/c1e93296e109a320e508e38118cf7d1fc2a4d1c2ec64de78565b3c445eb5/pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0", size = 33848 }, +] + [[package]] name = "platformdirs" version = "4.3.7" From 5fb5c89c665299b284dc0b5ca74a62509bdbad54 Mon Sep 17 00:00:00 2001 From: Gray Advantage Date: Wed, 23 Apr 2025 23:34:45 +0300 Subject: [PATCH 3/6] feat: pain --- Makefile | 19 +- app/{users => admin}/__init__.py | 0 app/admin/main.py | 11 + app/app.py | 39 +- app/bot/Dockerfile | 12 + app/bot/__init__.py | 0 app/bot/accessor.py | 460 ++++++++++++++++++ app/bot/manager.py | 194 ++++++++ app/bot/models.py | 189 +++++++ app/bot/schemas.py | 33 ++ app/bot/utils.py | 6 + app/bot/views.py | 435 +++++++++++++++++ app/core/accessor.py | 45 ++ app/core/accessor_base.py | 82 ++++ app/core/config.py | 11 + app/core/database/database.py | 17 +- .../versions/2025_04_18_1107-9b0891d63f2a_.py | 199 ++++++++ .../versions/2025_04_22_2053-ea84e7a02b20_.py | 58 +++ .../versions/2025_04_23_1609-17651c57241b_.py | 31 ++ .../versions/2025_04_23_2211-a0f9c95c9a02_.py | 39 ++ app/core/database/sqlalchemy_base.py | 11 +- app/core/manager.py | 8 +- app/core/session.py | 13 + app/core/store.py | 13 - app/fixtures/__init__.py | 0 app/fixtures/data.json | 224 +++++++++ app/fixtures/fixtures.py | 223 +++++++++ app/poller/Dockerfile | 1 - app/poller/poller.py | 10 +- app/users/models.py | 19 - docker-compose.yml | 13 + main.py | 6 - pyproject.toml | 2 + requirements.txt | 1 + uv.lock | 11 + 35 files changed, 2372 insertions(+), 63 deletions(-) rename app/{users => admin}/__init__.py (100%) create mode 100644 app/admin/main.py create mode 100644 app/bot/Dockerfile create mode 100644 app/bot/__init__.py create mode 100644 app/bot/accessor.py create mode 100644 app/bot/manager.py create mode 100644 app/bot/models.py create mode 100644 app/bot/schemas.py create mode 100644 app/bot/utils.py create mode 100644 app/bot/views.py create mode 100644 app/core/accessor_base.py create mode 100644 app/core/database/migrations/versions/2025_04_18_1107-9b0891d63f2a_.py create mode 100644 app/core/database/migrations/versions/2025_04_22_2053-ea84e7a02b20_.py create mode 100644 app/core/database/migrations/versions/2025_04_23_1609-17651c57241b_.py create mode 100644 app/core/database/migrations/versions/2025_04_23_2211-a0f9c95c9a02_.py create mode 100644 app/core/session.py delete mode 100644 app/core/store.py create mode 100644 app/fixtures/__init__.py create mode 100644 app/fixtures/data.json create mode 100644 app/fixtures/fixtures.py delete mode 100644 app/users/models.py delete mode 100644 main.py diff --git a/Makefile b/Makefile index e34da52..0526c7c 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,25 @@ -run: - python main.py - migrations: alembic revision --autogenerate migrate: alembic upgrade head +db: migrate migrations migrate + +dumpdata: + python -m app.fixtures.fixtures dump ./app/fixtures/data.json + +loaddata: + python -m app.fixtures.fixtures load ./app/fixtures/data.json + +down: + docker compose down + +up: + docker compose up --build -d + +reset: down up migrate loaddata + ruff: ruff check . diff --git a/app/users/__init__.py b/app/admin/__init__.py similarity index 100% rename from app/users/__init__.py rename to app/admin/__init__.py diff --git a/app/admin/main.py b/app/admin/main.py new file mode 100644 index 0000000..5bd49a6 --- /dev/null +++ b/app/admin/main.py @@ -0,0 +1,11 @@ +from aiohttp.web import run_app + +from app.app import setup_app + +if __name__ == "__main__": + app = setup_app() + + app.database.connect() + app.on_startup.append(app.accessors.admin_accessor.connect) + + run_app(app) \ No newline at end of file diff --git a/app/app.py b/app/app.py index c09fdd8..cc9fef8 100644 --- a/app/app.py +++ b/app/app.py @@ -1,14 +1,42 @@ -from aiohttp.web import Application as AiohttpApplication +from aiohttp.web import ( + Application as AiohttpApplication, + Request as AiohttpRequest, + View as AiohttpView, +) +from aiohttp_session import setup as setup_session +from app.admin.models import AdminModel +from app.admin.routes import setup_routes +from app.bot.manager import TelegramBotManager, setup_bot_api +from app.core.accessor import Accessors, setup_accessors from app.core.config import Config, setup_config from app.core.database.database import Database, setup_database -from app.core.store import Store, setup_store +from app.core.session import setup_session class Application(AiohttpApplication): + bot_api: TelegramBotManager database: Database config: Config - store: Store + accessors: Accessors + + +class Request(AiohttpRequest): + admin: AdminModel | None = None + + @property + def app(self) -> Application: + return super().app() + + +class View(AiohttpView): + @property + def request(self) -> Request: + return super().request + + @property + def data(self) -> dict: + return self.request.get("data", {}) app = Application() @@ -17,5 +45,8 @@ class Application(AiohttpApplication): def setup_app() -> Application: setup_config(app) setup_database(app) - setup_store(app) + setup_bot_api(app) + setup_accessors(app) + setup_session(app, key=app.config.session.key) + setup_routes(app) return app diff --git a/app/bot/Dockerfile b/app/bot/Dockerfile new file mode 100644 index 0000000..a7fb119 --- /dev/null +++ b/app/bot/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-alpine +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /project +COPY pyproject.toml uv.lock ./ + +RUN uv sync --compile-bytecode --no-cache --no-dev +ENV PATH="/project/.venv/bin:$PATH" + +COPY . . + +CMD python -m app.bot.views \ No newline at end of file diff --git a/app/bot/__init__.py b/app/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/accessor.py b/app/bot/accessor.py new file mode 100644 index 0000000..6c92159 --- /dev/null +++ b/app/bot/accessor.py @@ -0,0 +1,460 @@ +from typing import cast + +from sqlalchemy import exists, func, insert, select, update +from sqlalchemy.orm import selectinload + +from app.bot.models import ( + AnswerStatusEnum, + GameModel, + GameStatusEnum, + QuestionModel, + QuestionToThemeModel, + RoundModel, + RoundToGameModel, + RoundTypeEnum, + TelegramUserModel, + TelegramUserToGameModel, + TelegramUserToRoundModel, + ThemeModel, + ThemeToRoundModel, +) +from app.bot.schemas import Chat, Message, User +from app.core.accessor_base import BaseAccessor + + +class UserAccessor(BaseAccessor): + async def get_or_create(self, tele_user: User) -> TelegramUserModel: + exp = select(TelegramUserModel).where(TelegramUserModel.id == tele_user.id) + if (user := await self.scalar(exp)) is not None: + return cast(TelegramUserModel, user) + + exp1 = ( + insert(TelegramUserModel) + .values(id=tele_user.id, username=tele_user.username) + .returning(TelegramUserModel) + ) + return cast(TelegramUserModel, await self.scalar(exp1)) + + async def get_by_id(self, user_id: int) -> TelegramUserModel: + exp = select(TelegramUserModel).where(TelegramUserModel.id == user_id) + return await self.scalar(exp) + + +class GameAccessor(BaseAccessor): # noqa: PLR0904 + async def complete(self, game: GameModel) -> None: + exp = ( + update(GameModel) + .where(GameModel.id == game.id) + .values(status=GameStatusEnum.COMPLETED) + ) + await self.execute(exp) + + async def get_active_game(self, chat: Chat) -> GameModel | None: + exp = ( + select(GameModel) + .where(GameModel.chat_id == chat.id) + .where(GameModel.status != GameStatusEnum.COMPLETED) + ) + return await self.scalar(exp) + + async def get(self, chat_id: int, master_id: int) -> GameModel | None: + exp = ( + select(GameModel) + .where(GameModel.chat_id == chat_id) + .where(GameModel.master_id == master_id) + .where(GameModel.status != GameStatusEnum.COMPLETED) + ) + return await self.scalar(exp) + + async def get_by_id(self, game_id: int) -> GameModel | None: + return await self.scalar(select(GameModel).where(GameModel.id == game_id)) + + async def create(self, chat_id: int, master_id: int) -> GameModel: + if (game := await self.get(chat_id, master_id)) is not None: + return game + + exp = ( + insert(GameModel) + .values(chat_id=chat_id, master_id=master_id, status=GameStatusEnum.LOBBY) + .returning(GameModel) + ) + return cast(GameModel, await self.scalar(exp)) + + async def add_player(self, user: User, game_chat: Chat) -> bool: + await self.app.accessors.user_accessor.get_or_create(user) + exp = ( + select(TelegramUserToGameModel) + .join(GameModel, TelegramUserToGameModel.game_id == GameModel.id) + .where(TelegramUserToGameModel.user_id == user.id) + .where(GameModel.chat_id == game_chat.id) + .where(GameModel.status == GameStatusEnum.LOBBY) + ) + if await self.scalar(exp) is None: + game_id_stmt = ( + select(GameModel.id) + .where(GameModel.chat_id == game_chat.id) + .where(GameModel.status == GameStatusEnum.LOBBY) + ) + game_id = await self.scalar(game_id_stmt) + if game_id is None: + return False + + insert_stmt = insert(TelegramUserToGameModel).values( + user_id=user.id, + game_id=game_id, + ) + await self.execute(insert_stmt) + return True + return False + + async def all_users(self, chat: Chat) -> list[TelegramUserModel]: + exp = ( + select(TelegramUserModel) + .join( + TelegramUserToGameModel, + TelegramUserToGameModel.user_id == TelegramUserModel.id, + ) + .join(GameModel, TelegramUserToGameModel.game_id == GameModel.id) + .where(GameModel.chat_id == chat.id) + .where(GameModel.status != GameStatusEnum.COMPLETED) + ) + return list(await self.scalars(exp)) + + async def next_round(self, chat: Chat) -> bool: + game = await self.get_active_game(chat) + if game is None: + raise RuntimeError("Game not found") + + if game.status == GameStatusEnum.ROUND_1: + exp = ( + update(GameModel) + .where(GameModel.id == game.id) + .values(status=GameStatusEnum.COMPLETED) + ) + await self.execute(exp) + return False # Next? + + exp = ( + update(GameModel) + .where(GameModel.id == game.id) + .values(status=GameStatusEnum.ROUND_1) + ) + await self.execute(exp) + + exp1 = ( + insert(RoundModel).values(type=RoundTypeEnum.ROUND_1).returning(RoundModel) + ) + round_ = await self.scalar(exp1) + if round_ is None: + raise RuntimeError("Round not found") + + exp2 = ( + insert(RoundToGameModel) + .values(game_id=game.id, round_id=round_.id) + .returning(RoundToGameModel) + ) + await self.execute(exp2) + + exp3 = ( + select(ThemeModel) + .options(selectinload(ThemeModel.questions)) + .order_by(func.random()) + .limit(3) + ) + themes = await self.scalars(exp3) + + for theme in themes: + exp4 = insert(ThemeToRoundModel).values( + theme_id=theme.id, round_id=round_.id + ) + await self.execute(exp4) + + for question in theme.questions: + exp5 = insert(QuestionToThemeModel).values( + question_id=question.id, + theme_id=theme.id, + round_id=round_.id, + ) + await self.execute(exp5) + return True # Next? + + async def get_current_round(self, chat: Chat) -> RoundModel: + game = await self.get_active_game(chat) + exp = ( + select(RoundModel) + .join(RoundToGameModel, RoundModel.id == RoundToGameModel.round_id) + .where( + RoundModel.type == game.status, + RoundToGameModel.game_id == game.id, + ) + ) + return await self.scalar(exp) + + async def all_questions(self, chat: Chat) -> list[list[QuestionToThemeModel]]: + round_ = await self.get_current_round(chat) + if round_ is None: + return [] + + stmt = ( + select(QuestionToThemeModel) + .where(QuestionToThemeModel.round_id == round_.id) + .options( + selectinload(QuestionToThemeModel.question), + selectinload(QuestionToThemeModel.theme), + ) + .order_by(QuestionToThemeModel.theme_id) + ) + + records = await self.scalars(stmt) + + grouped: dict[int, list[QuestionToThemeModel]] = {} + for item in records: + grouped.setdefault(item.theme_id, []).append(item) + + return list(grouped.values()) + + async def set_choice_user( + self, + chat: Chat, + choice: TelegramUserModel, + ) -> TelegramUserModel: + game = await self.get_active_game(chat) + + exp = ( + update(GameModel) + .where(GameModel.id == game.id) + .values(choice_user_id=choice.id) + ) + await self.execute(exp) + return choice + + async def set_active_user_null(self, chat: Chat) -> None: + game = await self.get_active_game(chat) + + exp = ( + update(GameModel).where(GameModel.id == game.id).values(active_user_id=None) + ) + await self.execute(exp) + + async def set_active_user( + self, + chat: Chat, + active: User, + user_id: int, + question_id: int, + round_id: int, + ) -> None: + game = await self.get_active_game(chat) + + exp = ( + update(GameModel) + .where(GameModel.id == game.id) + .values(active_user_id=active.id) + ) + await self.execute(exp) + + exp1 = ( + update(TelegramUserToRoundModel) + .where( + TelegramUserToRoundModel.user_id == user_id, + TelegramUserToRoundModel.question_id == question_id, + TelegramUserToRoundModel.round_id == round_id, + ) + .values(state=AnswerStatusEnum.WAIT_ANSWERED) + ) + await self.execute(exp1) + + async def get_question_by_message( + self, msg_or_call: Message + ) -> QuestionModel: + round_ = await self.get_current_round(msg_or_call.chat) + + exp1 = ( + select(TelegramUserToRoundModel) + .where(TelegramUserToRoundModel.user_id == msg_or_call.from_.id) + .where(TelegramUserToRoundModel.round_id == round_.id) + .where(TelegramUserToRoundModel.state == AnswerStatusEnum.WAIT_ANSWERED) + ) + res = await self.scalar(exp1) + question_id, round_id = res.question_id, res.round_id + + exp2 = select(QuestionModel).where(QuestionModel.id == question_id) + theme_id = (await self.scalar(exp2)).theme_id + + exp = select(QuestionToThemeModel).where( + QuestionToThemeModel.round_id == round_id, + QuestionToThemeModel.theme_id == theme_id, + QuestionToThemeModel.question_id == question_id, + ) + question_to_theme = await self.scalar(exp) + + exp1 = ( + select(QuestionModel) + .options(selectinload(QuestionModel.theme)) + .where(QuestionModel.id == question_to_theme.question_id) + ) + + return await self.scalar(exp1) + + async def get_active_user(self, chat: Chat) -> TelegramUserModel | None: + game = await self.get_active_game(chat) + if game is None: + return None + + exp = select(TelegramUserModel).where( + TelegramUserModel.id == game.active_user_id + ) + return await self.scalar(exp) + + async def set_user_answered( + self, + user_id: int, + question_id: int, + round_id: int, + ) -> None: + exp = ( + update(TelegramUserToRoundModel) + .where( + TelegramUserToRoundModel.user_id == user_id, + TelegramUserToRoundModel.question_id == question_id, + TelegramUserToRoundModel.round_id == round_id, + ) + .values(state=AnswerStatusEnum.ANSWERED) + ) + await self.execute(exp) + + async def is_answered(self, user_id: int, question_id: int, round_id: int) -> bool: + exp = select(TelegramUserToRoundModel).where( + TelegramUserToRoundModel.user_id == user_id, + TelegramUserToRoundModel.question_id == question_id, + TelegramUserToRoundModel.round_id == round_id, + TelegramUserToRoundModel.state == AnswerStatusEnum.ANSWERED, + ) + return await self.scalar(exp) is not None + + async def get_question_by_user_round( + self, + user_id: int, + question_id: int, + round_: RoundModel, + ) -> QuestionModel: + exp = ( + select(TelegramUserToRoundModel) + .options(selectinload(TelegramUserToRoundModel.question)) + .where( + TelegramUserToRoundModel.user_id == user_id, + TelegramUserToRoundModel.round_id == round_.id, + TelegramUserToRoundModel.question_id == question_id, + TelegramUserToRoundModel.state == AnswerStatusEnum.ANSWERED, + ) + ) + res = await self.scalar(exp) + return res.question + + async def add_score(self, user_id: int, game_id: int, score: int) -> None: + exp = ( + update(TelegramUserToGameModel) + .where( + TelegramUserToGameModel.user_id == user_id, + TelegramUserToGameModel.game_id == game_id, + ) + .values( + score=TelegramUserToGameModel.score + score, + ) + ) + await self.execute(exp) + + async def set_question_answered( + self, theme_id: int, question_id: int, round_id: int + ) -> None: + exp = ( + update(QuestionToThemeModel) + .where( + QuestionToThemeModel.theme_id == theme_id, + QuestionToThemeModel.question_id == question_id, + QuestionToThemeModel.round_id == round_id, + ) + .values(status=AnswerStatusEnum.ANSWERED) + ) + await self.execute(exp) + + async def has_questions(self, round_: RoundModel) -> bool: + exp = select( + exists(QuestionToThemeModel).where( + QuestionToThemeModel.round_id == round_.id, + QuestionToThemeModel.status == AnswerStatusEnum.NOT_ANSWERED, + ), + ) + return await self.scalar(exp) + + async def all_profiles(self, game_id: int) -> list[TelegramUserToGameModel]: + exp = ( + select(TelegramUserToGameModel) + .options(selectinload(TelegramUserToGameModel.user)) + .where(TelegramUserToGameModel.game_id == game_id) + ) + return list(await self.scalars(exp)) + + async def summarize(self, profile: TelegramUserToGameModel, is_win: bool) -> None: + exp = ( + update(TelegramUserModel) + .where(TelegramUserModel.id == profile.user_id) + .values( + score=TelegramUserModel.score + profile.score, + win_count=( + TelegramUserModel.win_count + 1 + if is_win + else TelegramUserModel.win_count + ), + loss_count=( + TelegramUserModel.loss_count + 1 + if not is_win + else TelegramUserModel.loss_count + ), + ) + ) + await self.execute(exp) + + async def generate_users_answer_status( + self, + chat: Chat, + question_id: int, + round_id: int, + ) -> None: + users = await self.all_users(chat) + + for user in users: + exp = insert(TelegramUserToRoundModel).values( + user_id=user.id, + question_id=question_id, + round_id=round_id, + ) + await self.execute(exp) + + async def has_user_not_answered(self, round_id: int, question_id: int) -> bool: + exp = select( + exists(TelegramUserToRoundModel).where( + TelegramUserToRoundModel.question_id == question_id, + TelegramUserToRoundModel.round_id == round_id, + TelegramUserToRoundModel.state == AnswerStatusEnum.NOT_ANSWERED, + ) + ) + return await self.scalar(exp) + + async def has_answer(self, user_id: int, round_id: int, question_id: int) -> bool: + exp = ( + select(TelegramUserToRoundModel) + .where( + TelegramUserToRoundModel.user_id == user_id, + TelegramUserToRoundModel.question_id == question_id, + TelegramUserToRoundModel.round_id == round_id, + ) + ) + return (await self.scalar(exp)).state == AnswerStatusEnum.ANSWERED + + async def get_question_by_id(self, question_id: int) -> QuestionModel: + exp = ( + select(QuestionModel) + .where(QuestionModel.id == question_id) + ) + return await self.scalar(exp) diff --git a/app/bot/manager.py b/app/bot/manager.py new file mode 100644 index 0000000..f9f2f39 --- /dev/null +++ b/app/bot/manager.py @@ -0,0 +1,194 @@ +import asyncio +import json +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any, Literal, cast + +import aiohttp +from aio_pika.abc import AbstractIncomingMessage + +from app.bot.schemas import CallbackQuery, Chat, Message, TelegramUpdate +from app.core.manager import RabbitMQManager + +if TYPE_CHECKING: + from app.app import Application + + +class TelegramBotManager: + def __init__(self, app: "Application"): + self.app = app + self._token = app.config.bot.token + self._base_url = "https://api.telegram.org/bot" + self._session: aiohttp.ClientSession | None = None + self._handlers: list[ + tuple[list[str] | None, Callable[[Message], Awaitable[None]]] + ] = [] + self._callback_handlers: list[ + tuple[str | None, Callable[[CallbackQuery], Awaitable[None]]] + ] = [] + + def build_method_url(self, method_name: str) -> str: + return f"{self._base_url}{self._token}/{method_name}" + + async def _mainloop(self) -> None: + await self.connect() + rabbit = RabbitMQManager( + amqp_url=self.app.config.rabbitmq.url, + queue_name=self.app.config.rabbitmq.input_queue, + ) + await rabbit.connect() + await rabbit.consume(self.get_update) + + await asyncio.Event().wait() + + def mainloop(self) -> None: + asyncio.run(self._mainloop()) + + async def connect(self) -> None: + self._session = aiohttp.ClientSession() + + async def close(self) -> None: + if self._session: + await self._session.close() + + async def _post(self, method: str, json: dict[str, Any]) -> dict[Any, Any]: + if self._session is None: + raise RuntimeError("TelegramBotManager is not connected") + + async with self._session.post(self.build_method_url(method), json=json) as resp: + response_data: dict[str, Any] = await resp.json() + if not response_data.get("ok"): + raise RuntimeError(f"Telegram API error: {response_data}") + return cast(dict[Any, Any], response_data["result"]) + + async def get_update(self, msg: AbstractIncomingMessage) -> None: + async with msg.process(): + data = json.loads(msg.body) + update = TelegramUpdate(**data) + + if update.message and update.message.text: + text = update.message.text.strip() + is_command = text.startswith("/") + + for commands, handler in self._handlers: + if commands is None and not is_command: + await handler(update.message) + elif ( + commands is not None + and is_command + and any( + text.split()[0] == f"/{command}" for command in commands + ) + ): + await handler(update.message) + return + + elif update.callback_query and update.callback_query.data: + data = update.callback_query.data + for expected_data, handler in self._callback_handlers: + if expected_data is None or data == expected_data: + await handler(update.callback_query) + return + + def connect_handler( + self, commands: list[str] | None = None + ) -> Callable[ + [Callable[[Message], Awaitable[None]]], + Callable[[Message], Awaitable[None]], + ]: + def decorator( + func: Callable[[Message], Awaitable[None]], + ) -> Callable[[Message], Awaitable[None]]: + self._handlers.append((commands, func)) + return func + + return decorator + + def connect_callback_handler( + self, + data_value: str | None = None, + ) -> Callable[ + [Callable[[CallbackQuery], Awaitable[None]]], + Callable[[CallbackQuery], Awaitable[None]], + ]: + def decorator( + func: Callable[[CallbackQuery], Awaitable[None]], + ) -> Callable[[CallbackQuery], Awaitable[None]]: + self._callback_handlers.append((data_value, func)) + return func + + return decorator + + async def send_message( + self, + chat: Chat, + text: Any, + keyboard: list[list[tuple[str, str]]] | None = None, + parse_mode: Literal["MarkdownV2", "HTML", "Markdown"] | None = None, + reply_to_message_id: int | None = None, + ) -> dict[Any, Any]: + json_data = { + "chat_id": chat.id, + "text": str(text), + } + + if parse_mode is not None: + json_data["parse_mode"] = parse_mode + + if keyboard is not None: + inline_keyboard = [ + [{"text": label, "callback_data": data} for label, data in row] + for row in keyboard + ] + json_data["reply_markup"] = {"inline_keyboard": inline_keyboard} + + if reply_to_message_id is not None: + json_data["reply_parameters"] = {"message_id": reply_to_message_id} + + return await self._post("sendMessage", json=json_data) + + async def answer_callback_query( + self, + callback_query: CallbackQuery, + text: str = "", + show_alert: bool = False, + ) -> dict[Any, Any]: + payload = { + "callback_query_id": callback_query.id, + "text": text, + "show_alert": show_alert, + } + + if not text: + payload.pop("text") + + return await self._post("answerCallbackQuery", json=payload) + + async def edit_message_text( + self, + chat_id: int, + message_id: int, + text: str, + keyboard: list[list[tuple[str, str]]] | None = None, + parse_mode: Literal["MarkdownV2", "HTML", "Markdown"] | None = None, + ) -> dict[Any, Any]: + payload: dict[str, Any] = { + "chat_id": chat_id, + "message_id": message_id, + "text": text, + } + + if keyboard: + inline_keyboard = [ + [{"text": label, "callback_data": data} for label, data in row] + for row in keyboard + ] + payload["reply_markup"] = {"inline_keyboard": inline_keyboard} + + if parse_mode is not None: + payload["parse_mode"] = parse_mode + + return await self._post("editMessageText", json=payload) + + +def setup_bot_api(app: "Application") -> None: + app.bot_api = TelegramBotManager(app) diff --git a/app/bot/models.py b/app/bot/models.py new file mode 100644 index 0000000..423f5c4 --- /dev/null +++ b/app/bot/models.py @@ -0,0 +1,189 @@ +from datetime import datetime, timedelta +from enum import StrEnum + +from sqlalchemy import ( + TIMESTAMP, + BigInteger, + CheckConstraint, + Enum as PgEnum, + ForeignKey, + Interval, + PrimaryKeyConstraint, + SmallInteger, + String, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database.mixins import IDMixin +from app.core.database.sqlalchemy_base import BaseModel + + +class GameStatusEnum(StrEnum): + LOBBY = "lobby" + ROUND_1 = "round_1" + ROUND_2 = "round_2" + ROUND_3 = "round_3" + COMPLETED = "completed" + + +class RoundTypeEnum(StrEnum): + ROUND_1 = "round_1" + ROUND_2 = "round_2" + ROUND_3 = "round_3" + + +class AnswerStatusEnum(StrEnum): + NOT_ANSWERED = "not_answered" + ANSWERED = "answered" + WAIT_ANSWERED = "wait_answered" + + +class TelegramUserModel(BaseModel): + __tablename__ = "telegram_user" + + __table_args__ = ( + CheckConstraint("win_count >= 0", name="win_count_non_negative"), + CheckConstraint("loss_count >= 0", name="loss_count_non_negative"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) + username: Mapped[str] = mapped_column(String(64)) + score: Mapped[int] = mapped_column(default=0) + win_count: Mapped[int] = mapped_column(default=0) + loss_count: Mapped[int] = mapped_column(default=0) + + +class GameModel(IDMixin, BaseModel): + __tablename__ = "game" + + chat_id: Mapped[int] = mapped_column(BigInteger) + status: Mapped[GameStatusEnum] = mapped_column( + PgEnum(GameStatusEnum), + default=GameStatusEnum.LOBBY, + ) + master_id: Mapped[int] = mapped_column(ForeignKey("telegram_user.id")) + active_user_id: Mapped[int] = mapped_column( + ForeignKey("telegram_user.id"), + nullable=True, + ) + choice_user_id: Mapped[int] = mapped_column( + ForeignKey("telegram_user.id"), + nullable=True, + ) + + +class TelegramUserToGameModel(BaseModel): + __tablename__ = "telegram_user_to_game" + + user_id: Mapped[int] = mapped_column( + ForeignKey("telegram_user.id"), + primary_key=True, + ) + game_id: Mapped[int] = mapped_column(ForeignKey("game.id"), primary_key=True) + score: Mapped[int] = mapped_column(default=0) + + user: Mapped[TelegramUserModel] = relationship() + + +class RoundModel(IDMixin, BaseModel): + __tablename__ = "round" + + type: Mapped[RoundTypeEnum] = mapped_column(PgEnum(RoundTypeEnum)) + + @property + def base_score(self): + data = { + RoundTypeEnum.ROUND_1: 100, + RoundTypeEnum.ROUND_2: 200, + RoundTypeEnum.ROUND_3: 300, + } + return data[self.type] + + +class ThemeModel(IDMixin, BaseModel): + __tablename__ = "theme" + + title: Mapped[str] = mapped_column(String(255)) + questions: Mapped[list["QuestionModel"]] = relationship( + back_populates="theme", + cascade="all, delete-orphan", + ) + + +class QuestionModel(IDMixin, BaseModel): + __tablename__ = "question" + + text: Mapped[str] = mapped_column(String(255)) + answer: Mapped[str] = mapped_column(String(255)) + hard_level: Mapped[int] = mapped_column(SmallInteger) + theme_id: Mapped[int] = mapped_column(ForeignKey("theme.id")) + + theme: Mapped[ThemeModel] = relationship( + back_populates="questions", + ) + + +class QuestionToThemeModel(BaseModel): + __tablename__ = "question_to_theme" + __table_args__ = ( + PrimaryKeyConstraint( + "round_id", + "theme_id", + "question_id", + name="pk_question_to_theme", + ), + ) + + round_id: Mapped[int] = mapped_column(ForeignKey("round.id")) + theme_id: Mapped[int] = mapped_column(ForeignKey("theme.id")) + question_id: Mapped[int] = mapped_column(ForeignKey("question.id")) + + status: Mapped[AnswerStatusEnum] = mapped_column( + PgEnum(AnswerStatusEnum), + default=AnswerStatusEnum.NOT_ANSWERED, + ) + question: Mapped[QuestionModel] = relationship() + theme: Mapped[ThemeModel] = relationship() + + +class RoundToGameModel(BaseModel): + __tablename__ = "round_to_game" + + round_id: Mapped[int] = mapped_column(ForeignKey("round.id"), primary_key=True) + game_id: Mapped[int] = mapped_column(ForeignKey("game.id"), primary_key=True) + + +class ThemeToRoundModel(BaseModel): + __tablename__ = "theme_to_round" + + theme_id: Mapped[int] = mapped_column(ForeignKey("theme.id"), primary_key=True) + round_id: Mapped[int] = mapped_column(ForeignKey("round.id"), primary_key=True) + + +class TelegramUserToRoundModel(BaseModel): + __tablename__ = "telegram_user_to_round" + + user_id: Mapped[int] = mapped_column( + ForeignKey("telegram_user.id"), + primary_key=True, + ) + question_id: Mapped[int] = mapped_column( + ForeignKey("question.id"), + primary_key=True, + ) + round_id: Mapped[int] = mapped_column(ForeignKey("round.id"), primary_key=True) + state: Mapped[AnswerStatusEnum] = mapped_column( + PgEnum(AnswerStatusEnum), + default=AnswerStatusEnum.NOT_ANSWERED, + ) + + question: Mapped[QuestionModel] = relationship() + + +class TimersModel(BaseModel): + __tablename__ = "timers" + + round_id: Mapped[int] = mapped_column(ForeignKey("round.id"), primary_key=True) + create_at: Mapped[datetime] = mapped_column(TIMESTAMP, default=datetime.utcnow) + question_id: Mapped[int] = mapped_column(ForeignKey("question.id")) + duration: Mapped[timedelta] = mapped_column(Interval) diff --git a/app/bot/schemas.py b/app/bot/schemas.py new file mode 100644 index 0000000..b90e341 --- /dev/null +++ b/app/bot/schemas.py @@ -0,0 +1,33 @@ +from typing import Literal + +from pydantic import BaseModel, Field + + +class User(BaseModel): + id: int + username: str + + +class Chat(BaseModel): + id: int + type: Literal["private", "group", "supergroup", "channel"] = "group" + + +class Message(BaseModel): + message_id: int + text: str | None = None + chat: Chat + from_: User = Field(alias="from") + + +class CallbackQuery(BaseModel): + id: str + from_: User = Field(alias="from") + data: str + message: Message + + +class TelegramUpdate(BaseModel): + update_id: int + message: Message | None = None + callback_query: CallbackQuery | None = None diff --git a/app/bot/utils.py b/app/bot/utils.py new file mode 100644 index 0000000..7c5960a --- /dev/null +++ b/app/bot/utils.py @@ -0,0 +1,6 @@ +import re + + +def escape_markdown_v2(text: str) -> str: + escape_chars = r"_*[]()~`>#+-=|{}.!\\" + return re.sub(f"([{re.escape(escape_chars)}])", r"\\\1", text) \ No newline at end of file diff --git a/app/bot/views.py b/app/bot/views.py new file mode 100644 index 0000000..b636419 --- /dev/null +++ b/app/bot/views.py @@ -0,0 +1,435 @@ +from random import choice + +from app.app import app, setup_app +from app.bot.models import AnswerStatusEnum, TelegramUserModel +from app.bot.schemas import CallbackQuery, Chat, Message +from app.bot.utils import escape_markdown_v2 + +setup_app() +bot = app.bot_api +app.database.connect() + + +async def generate_question_keyboard( + call_or_chat: CallbackQuery | Chat, + user: TelegramUserModel, +) -> None: + if isinstance(call_or_chat, CallbackQuery): + groups = await app.accessors.game_accessor.all_questions( + call_or_chat.message.chat, + ) + if not groups: + await bot.send_message( + call_or_chat.message.chat, + "Нет вопросов в текущем раунде", + ) + return + else: + groups = await app.accessors.game_accessor.all_questions(call_or_chat) + if not groups: + await bot.send_message(call_or_chat, "Нет вопросов в текущем раунде") + return + + lines = [] + keyboard = [] + + for idx, group in enumerate(groups, start=1): + theme = group[0].theme + lines.append(f"{idx}. {theme.title}") + + row = [] + for qtt in sorted(group, key=lambda x: x.question.hard_level): + price = qtt.question.hard_level * 100 + + if qtt.status == AnswerStatusEnum.ANSWERED: + row.append(("-X-X-", "answered")) + else: + row.append( + (f"{idx}) {price}", f"btn_choice:{qtt.round_id}:{qtt.question_id}"), + ) + keyboard.append(row) + + if isinstance(call_or_chat, CallbackQuery): + await bot.edit_message_text( + call_or_chat.message.chat.id, + call_or_chat.message.message_id, + f"Итак, начнём!\n\nВыбирает тему @{user.username}:\n" + "\n".join(lines), + keyboard=keyboard, + ) + else: + await bot.send_message( + call_or_chat, + f"А мы продолжаем!\n\nВыбирает тему @{user.username}:\n" + "\n".join(lines), + keyboard=keyboard, + ) + + +async def summarize_the_results(chat: Chat, game_id: int) -> None: + profiles = await app.accessors.game_accessor.all_profiles(game_id) + + profiles = sorted(profiles, key=lambda p: p.score, reverse=True) + lines = [] + for i, profile in enumerate(profiles): + lines.append(f"@{profile.user.username} — {profile.score}") + await app.accessors.game_accessor.summarize(profile, i == 0) + + await bot.send_message( + chat, + "Что ж, наша игра подходит к концу, и наш общий счёт:\n\n" + "\n".join(lines), + ) + + +@bot.connect_handler(commands=["start"]) +async def start(message: Message) -> None: + if message.chat.type == "private": + await bot.send_message( + message.chat, + "Привет, это бот `Своя игра`, добавь меня в игру и мы сыграем вместе", + ) + return + + if await app.accessors.game_accessor.get_active_game(message.chat) is not None: + await bot.send_message(message.chat, "В этом чате уже есть активная игра") + return + + master = await app.accessors.user_accessor.get_or_create(message.from_) + await app.accessors.game_accessor.create(message.chat.id, master.id) + await bot.send_message( + message.chat, + "Новая игра\n\nНет\nСвоя игра!", + [ + [("Начать игру", "start_game")], + [("Присоединиться", "connect_to_game")], + ], + ) + + +@bot.connect_handler(commands=["stop"]) +async def stop(message: Message) -> None: + game = await app.accessors.game_accessor.get_active_game(message.chat) + if game is None: + return + + await app.accessors.game_accessor.complete(game) + + if game.master_id == message.from_.id: + await bot.send_message( + message.chat, + "Игра завершена досрочно" + ) + + +@bot.connect_callback_handler("start_game") +async def start_game_handler(call: CallbackQuery) -> None: + if ( + await app.accessors.game_accessor.get( + call.message.chat.id, + call.from_.id, + ) + is None + ): + await bot.answer_callback_query( + call, + "Сори, только для ведущего!", + show_alert=True, + ) + return + await bot.answer_callback_query(call) + await app.accessors.game_accessor.next_round(call.message.chat) + users = await app.accessors.game_accessor.all_users(call.message.chat) + user = await app.accessors.game_accessor.set_choice_user( + call.message.chat, + choice(users), + ) + + await generate_question_keyboard(call, user) + + +@bot.connect_callback_handler("connect_to_game") +async def connect_handler(call: CallbackQuery) -> None: + if ( + await app.accessors.game_accessor.get( + call.message.chat.id, + call.from_.id, + ) + is not None + ): + await bot.answer_callback_query(call, "Ты же ведущий...", show_alert=True) + return + + if not await app.accessors.game_accessor.add_player(call.from_, call.message.chat): + await bot.answer_callback_query(call, "Ты уже участвуешь!", show_alert=True) + return + + users = await app.accessors.game_accessor.all_users(call.message.chat) + await bot.edit_message_text( + chat_id=call.message.chat.id, + message_id=call.message.message_id, + text="Новая игра\n" + "\n" + "Нет\n" + "Своя игра!\n" + "\n" + "Наши участники:" + "\n" + "\n".join(["- @" + user.username for user in users]), + keyboard=[ + [("Начать игру", "start_game")], + [("Присоединиться", "connect_to_game")], + ], + ) + + +@bot.connect_callback_handler() +async def choice_button(call: CallbackQuery) -> None: + if call.data.startswith("answered"): + await bot.answer_callback_query(call) + return + + if call.data.startswith("btn_choice"): + game = await app.accessors.game_accessor.get_active_game(call.message.chat) + round_id, question_id = map(int, call.data.split(":")[1:]) + + if game.choice_user_id != call.from_.id: + await bot.answer_callback_query( + call, + "Не ты выбираешь тему!", + show_alert=True, + ) + return + + await app.accessors.game_accessor.generate_users_answer_status( + call.message.chat, + question_id, + round_id, + ) + qst = await app.accessors.game_accessor.get_question_by_id(question_id) + await bot.edit_message_text( + call.message.chat.id, + call.message.message_id, + f"Окей, игрок @{call.from_.username}, выбрал вопрос:\n\n{qst.text}", + keyboard=[ + [("Ответить", f"btn_answer:{round_id}:{question_id}")], + ], + ) + elif call.data.startswith("btn_answer"): + round_id, question_id = map(int, call.data.split(":")[1:]) + game = await app.accessors.game_accessor.get_active_game(call.message.chat) + + if game.master_id == call.from_.id: + await bot.answer_callback_query( + call, + "Ты ведущий! Помни об этом", + show_alert=True, + ) + + if await app.accessors.game_accessor.has_answer( + call.from_.id, + round_id, + question_id, + ): + await bot.answer_callback_query(call, "Ты уже ответил!", show_alert=True) + return + + await app.accessors.game_accessor.set_active_user( + call.message.chat, + call.from_, + call.from_.id, + question_id, + round_id, + ) + qst = await app.accessors.game_accessor.get_question_by_id(question_id) + await bot.edit_message_text( + call.message.chat.id, + call.message.message_id, + f"Отвечает @{call.from_.username}\n" + "\n" + f"{qst.text}\n" + "\n" + "Следующие ваше сообщение будет считаться ответом", + ) + else: + result, user_id, game_id, reply, question_id = call.data.split(":") + user_id, game_id, reply, question_id = list( + map(int, [user_id, game_id, reply, question_id]) + ) + game = await app.accessors.game_accessor.get_by_id(game_id) + round_ = await app.accessors.game_accessor.get_current_round( + Chat(id=game.chat_id), + ) + user = await app.accessors.user_accessor.get_by_id(user_id) + qst = await app.accessors.game_accessor.get_question_by_user_round( + user_id, + question_id, + round_, + ) + score = qst.hard_level * round_.base_score + + await bot.edit_message_text( + call.message.chat.id, + call.message.message_id, + call.message.text, + ) + + if result == "correct": + await app.accessors.game_accessor.add_score(user_id, game_id, score) + await bot.send_message( + Chat(id=game.chat_id), + f"Иии.. ваш ответ верен!\n+ {score} очков", + reply_to_message_id=reply, + ) + await app.accessors.game_accessor.set_choice_user( + Chat(id=game.chat_id), user + ) + await app.accessors.game_accessor.set_active_user_null( + Chat(id=game.chat_id), + ) + + if await app.accessors.game_accessor.has_questions(round_): + await generate_question_keyboard(Chat(id=game.chat_id), user) + else: + next_ = await app.accessors.game_accessor.next_round( + Chat(id=game.chat_id), + ) + if next_ is False: + await summarize_the_results(Chat(id=game.chat_id), game_id) + return + else: + await app.accessors.game_accessor.add_score(user_id, game_id, -score) + await bot.send_message( + Chat(id=game.chat_id), + f"Иии.. увы, ваш ответ неверен!\n- {score} очков", + reply_to_message_id=reply, + ) + await app.accessors.game_accessor.set_active_user_null( + Chat(id=game.chat_id), + ) + if await app.accessors.game_accessor.has_user_not_answered( + round_.id, + question_id, + ): + await bot.send_message( + Chat(id=game.chat_id), + f"Может кто-то другой ответит на вопрос?:\n\n{qst.text}", + keyboard=[ + [("Ответить", f"btn_answer:{round_.id}:{question_id}")], + ], + ) + return + + await bot.send_message( + Chat(id=game.chat_id), + "Пу пу пу, никто не ответил, правильно\n" + "\n" + "А правильный ответ был\n" + f"{qst.answer}", + ) + + if await app.accessors.game_accessor.has_questions(round_): + await generate_question_keyboard(Chat(id=game.chat_id), user) + else: + next_ = await app.accessors.game_accessor.next_round( + Chat(id=game.chat_id), + ) + if next_ is False: + await summarize_the_results(Chat(id=game.chat_id), game_id) + return + + await bot.answer_callback_query(call) + + +@bot.connect_handler() +async def start_game(message: Message) -> None: + game = await app.accessors.game_accessor.get_active_game(message.chat) + if game is None: + return + + active_user = await app.accessors.game_accessor.get_active_user(message.chat) + if active_user is None: + return + + if message.from_.id == active_user.id: + question = await app.accessors.game_accessor.get_question_by_message(message) + round_ = await app.accessors.game_accessor.get_current_round(message.chat) + + if await app.accessors.game_accessor.is_answered( + message.from_.id, + question.id, + round_.id, + ): + return + + await app.accessors.game_accessor.set_user_answered( + message.from_.id, + question.id, + round_.id, + ) + await app.accessors.game_accessor.set_question_answered( + question.theme.id, + question.id, + round_.id, + ) + + try: + await bot.send_message( + Chat(id=game.master_id), + f"Игрок @{escape_markdown_v2(message.from_.username)} выбрал вопрос:\n" + "```\n" + f"{question.text}" + "```\n" + "\n" + "Ответ:\n" + "```\n" + f"{question.answer}" + "```\n" + "\n" + "Ответ игрока:\n" + "```\n" + f"{message.text}" + f"```", + parse_mode="MarkdownV2", + keyboard=[ + [ + ( + "Верно", + f"correct:{message.from_.id}:{game.id}:{message.message_id}:{question.id}", + ), + ( + "Неверно", + f"wrong:{message.from_.id}:{game.id}:{message.message_id}:{question.id}", + ), + ], + ], + ) + except RuntimeError as err: + if "Forbidden: bot can't initiate conversation with a user" in str(err): + await bot.send_message( + message.chat, + "Я не могу написать ведущему первым, " + "чтобы отправить на проверку ответ\n" + "\n" + "Ответ не засчитан", + ) + + await app.accessors.game_accessor.set_active_user_null(message.chat) + + if await app.accessors.game_accessor.has_questions(round_): + user = await app.accessors.user_accessor.get_by_id(message.from_.id) + await generate_question_keyboard(message.chat, user) + else: + next_ = await app.accessors.game_accessor.next_round( + Chat(id=game.chat_id), + ) + if next_ is False: + await summarize_the_results(message.chat, game.id) + + return + + await bot.send_message( + message.chat, + "Ваш ответ мы отправили ведущему на проверку", + reply_to_message_id=message.message_id, + ) + + +if __name__ == "__main__": + bot.mainloop() diff --git a/app/core/accessor.py b/app/core/accessor.py index e69de29..d83d1f5 100644 --- a/app/core/accessor.py +++ b/app/core/accessor.py @@ -0,0 +1,45 @@ +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING + +from sqlalchemy.ext.asyncio import ( + AsyncSession, +) + +from app.admin.accessor import AdminAccessor, ThemeAccessor +from app.bot.accessor import GameAccessor, UserAccessor +from app.core.accessor_base import BaseAccessor + +if TYPE_CHECKING: + from app.app import Application + + +@asynccontextmanager +async def transaction(db: BaseAccessor) -> AsyncGenerator[AsyncSession, None]: + session = db.get_current_session() + + if session: + async with session.begin_nested(): + yield session + else: + async with db.session() as session, session.begin(): + yield session + + +class Accessors: + base_accessor: BaseAccessor + user_accessor: UserAccessor + game_accessor: GameAccessor + admin_accessor: AdminAccessor + theme_accessor: ThemeAccessor + + def __init__(self, app: "Application"): + self.base_accessor = BaseAccessor(app) + self.user_accessor = UserAccessor(app) + self.game_accessor = GameAccessor(app) + self.admin_accessor = AdminAccessor(app) + self.theme_accessor = ThemeAccessor(app) + + +def setup_accessors(app: "Application") -> None: + app.accessors = Accessors(app) diff --git a/app/core/accessor_base.py b/app/core/accessor_base.py new file mode 100644 index 0000000..35ad13b --- /dev/null +++ b/app/core/accessor_base.py @@ -0,0 +1,82 @@ +from asyncio import current_task +from collections.abc import AsyncGenerator, Sequence +from contextlib import asynccontextmanager +from contextvars import ContextVar +from typing import TYPE_CHECKING, Any + +from sqlalchemy import Row +from sqlalchemy.engine import CursorResult, Result, ScalarResult +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_scoped_session, + async_sessionmaker, +) +from sqlalchemy.sql.base import Executable + +if TYPE_CHECKING: + from app.app import Application + + +class BaseAccessor: + def __init__(self, app: "Application"): + self.app = app + + self._current_session: ContextVar[AsyncSession | None] = ContextVar( + "current_session", + default=None, + ) + + @property + def session_maker(self) -> async_sessionmaker[AsyncSession]: + if self.app.database.session is None: + raise RuntimeError("DatabaseAccessor is not connected") + return self.app.database.session + + @asynccontextmanager + async def session(self) -> AsyncGenerator[AsyncSession, None]: + scoped_session = async_scoped_session( + session_factory=self.session_maker, + scopefunc=current_task, + ) + + async with scoped_session() as session: + token = self._current_session.set(session) + + yield session + await session.commit() + + self._current_session.reset(token) + await scoped_session.remove() + + def get_current_session(self) -> AsyncSession | None: + return self._current_session.get() + + async def execute( + self, + statement: Executable, + ) -> CursorResult[Any] | Result[Any]: + session = self.get_current_session() + + if session: + return await session.execute(statement) + + async with self.session() as session: + return await session.execute(statement) + + async def scalar(self, statement: Executable) -> Any | None: + return (await self.execute(statement)).scalar() + + async def scalars(self, statement: Executable) -> ScalarResult[Any]: + return (await self.execute(statement)).scalars() + + async def one(self, statement: Executable) -> Any: + return (await self.execute(statement)).one() + + async def one_or_none(self, statement: Executable) -> Any | None: + return (await self.execute(statement)).one_or_none() + + async def first(self, statement: Executable) -> Any | None: + return (await self.execute(statement)).first() + + async def all(self, statement: Executable) -> Sequence[Row[Any]]: + return (await self.execute(statement)).all() diff --git a/app/core/config.py b/app/core/config.py index b86576c..7b86cfc 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -9,10 +9,19 @@ from app.app import Application +class SessionConfig(BaseModel): + key: str + + class BotConfig(BaseModel): token: str = "..." +class AdminConfig(BaseSettings): + login: str = "admin" + password: str = "admin" + + class DatabaseConfig(BaseModel): host: str = "localhost" port: int = 5432 @@ -46,6 +55,8 @@ def url(self) -> str: class Config(BaseSettings): + session: SessionConfig + admin: AdminConfig bot: BotConfig database: DatabaseConfig rabbitmq: RabbitmqConfig diff --git a/app/core/database/database.py b/app/core/database/database.py index 897bdb0..15cd1a9 100644 --- a/app/core/database/database.py +++ b/app/core/database/database.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from sqlalchemy import URL from sqlalchemy.ext.asyncio import ( @@ -18,12 +18,11 @@ class Database: def __init__(self, app: "Application") -> None: self.app = app - self.engine: AsyncEngine | None = None self._db: type[DeclarativeBase] = BaseModel self.session: async_sessionmaker[AsyncSession] | None = None - async def connect(self, *args: Any, **kwargs: Any) -> None: + def connect(self) -> None: if self.app.config is None or self.app.config.database is None: raise ValueError("Configuration or Database is not properly initialized.") @@ -39,14 +38,18 @@ async def connect(self, *args: Any, **kwargs: Any) -> None: echo=True, future=True, ) - self.session = async_sessionmaker(self.engine, expire_on_commit=False) + self.session = async_sessionmaker( + self.engine, + autoflush=False, + autocommit=False, + expire_on_commit=False, + ) - async def disconnect(self, *args: Any, **kwargs: Any) -> None: + async def disconnect(self) -> None: if self.engine is None: raise ValueError("Engine is not properly initialized.") - await self.engine.dispose() def setup_database(app: "Application") -> None: - app.database = Database(app) \ No newline at end of file + app.database = Database(app) diff --git a/app/core/database/migrations/versions/2025_04_18_1107-9b0891d63f2a_.py b/app/core/database/migrations/versions/2025_04_18_1107-9b0891d63f2a_.py new file mode 100644 index 0000000..5991d6f --- /dev/null +++ b/app/core/database/migrations/versions/2025_04_18_1107-9b0891d63f2a_.py @@ -0,0 +1,199 @@ +"""empty message + +Revision ID: 9b0891d63f2a +Revises: 32d5607c5cc0 +Create Date: 2025-04-18 11:07:54.259471 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "9b0891d63f2a" +down_revision: str | None = "32d5607c5cc0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "question", + sa.Column("text", sa.String(length=255), nullable=False), + sa.Column("answer", sa.String(length=255), nullable=False), + sa.Column("hard_level", sa.SmallInteger(), nullable=False), + sa.Column("id", sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "round", + sa.Column( + "type", + sa.Enum("ROUND_1", "ROUND_2", "ROUND_3", name="roundtypeenum"), + nullable=False, + ), + sa.Column("id", sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "theme", + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("id", sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "game", + sa.Column("chat_id", sa.BigInteger(), nullable=False), + sa.Column( + "status", + sa.Enum( + "LOBBY", + "ROUND_1", + "ROUND_2", + "ROUND_3", + "COMPLETED", + name="gamestatusenum", + ), + nullable=False, + ), + sa.Column("master_id", sa.BigInteger(), nullable=False), + sa.Column("active_user_id", sa.BigInteger(), nullable=False), + sa.Column("choice_user_id", sa.BigInteger(), nullable=False), + sa.Column("id", sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint( + ["active_user_id"], + ["telegram_user.id"], + ), + sa.ForeignKeyConstraint( + ["choice_user_id"], + ["telegram_user.id"], + ), + sa.ForeignKeyConstraint( + ["master_id"], + ["telegram_user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "question_to_theme", + sa.Column("theme_id", sa.BigInteger(), nullable=False), + sa.Column("question_id", sa.BigInteger(), nullable=False), + sa.Column( + "status", + sa.Enum("NOT_ANSWERED", "ANSWERED", "WAIT_ANSWERED", name="answerstatusenum"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["question_id"], + ["question.id"], + ), + sa.ForeignKeyConstraint( + ["theme_id"], + ["theme.id"], + ), + sa.PrimaryKeyConstraint("theme_id", "question_id"), + ) + op.create_table( + "telegram_user_to_round", + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("question_id", sa.BigInteger(), nullable=False), + sa.Column("round_id", sa.BigInteger(), nullable=False), + sa.Column( + "state", + sa.Enum("NOT_ANSWERED", "ANSWERED", "WAIT_ANSWERED", name="answerstatusenum"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["question_id"], + ["question.id"], + ), + sa.ForeignKeyConstraint( + ["round_id"], + ["round.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["telegram_user.id"], + ), + sa.PrimaryKeyConstraint("user_id", "question_id", "round_id"), + ) + op.create_table( + "theme_to_round", + sa.Column("theme_id", sa.BigInteger(), nullable=False), + sa.Column("round_id", sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint( + ["round_id"], + ["round.id"], + ), + sa.ForeignKeyConstraint( + ["theme_id"], + ["theme.id"], + ), + sa.PrimaryKeyConstraint("theme_id", "round_id"), + ) + op.create_table( + "timers", + sa.Column("round_id", sa.BigInteger(), nullable=False), + sa.Column("create_at", sa.TIMESTAMP(), nullable=False), + sa.Column("question_id", sa.BigInteger(), nullable=False), + sa.Column("duration", sa.Interval(), nullable=False), + sa.ForeignKeyConstraint( + ["question_id"], + ["question.id"], + ), + sa.ForeignKeyConstraint( + ["round_id"], + ["round.id"], + ), + sa.PrimaryKeyConstraint("round_id"), + ) + op.create_table( + "round_to_game", + sa.Column("round_id", sa.BigInteger(), nullable=False), + sa.Column("game_id", sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint( + ["game_id"], + ["game.id"], + ), + sa.ForeignKeyConstraint( + ["round_id"], + ["round.id"], + ), + sa.PrimaryKeyConstraint("round_id", "game_id"), + ) + op.create_table( + "telegram_user_to_game", + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("game_id", sa.BigInteger(), nullable=False), + sa.Column("score", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["game_id"], + ["game.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["telegram_user.id"], + ), + sa.PrimaryKeyConstraint("user_id", "game_id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("telegram_user_to_game") + op.drop_table("round_to_game") + op.drop_table("timers") + op.drop_table("theme_to_round") + op.drop_table("telegram_user_to_round") + op.drop_table("question_to_theme") + op.drop_table("game") + op.drop_table("theme") + op.drop_table("round") + op.drop_table("question") + # ### end Alembic commands ### diff --git a/app/core/database/migrations/versions/2025_04_22_2053-ea84e7a02b20_.py b/app/core/database/migrations/versions/2025_04_22_2053-ea84e7a02b20_.py new file mode 100644 index 0000000..ae237fa --- /dev/null +++ b/app/core/database/migrations/versions/2025_04_22_2053-ea84e7a02b20_.py @@ -0,0 +1,58 @@ +"""empty message + +Revision ID: ea84e7a02b20 +Revises: 9b0891d63f2a +Create Date: 2025-04-22 20:53:23.929599 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "ea84e7a02b20" +down_revision: str | None = "9b0891d63f2a" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column("game", "active_user_id", existing_type=sa.BIGINT(), nullable=True) + op.alter_column("game", "choice_user_id", existing_type=sa.BIGINT(), nullable=True) + op.add_column("question", sa.Column("theme_id", sa.BigInteger(), nullable=False)) + op.create_foreign_key( + op.f("fk_question_theme_id_theme"), "question", "theme", ["theme_id"], ["id"] + ) + op.add_column( + "question_to_theme", sa.Column("round_id", sa.BigInteger(), nullable=False) + ) + op.create_foreign_key( + op.f("fk_question_to_theme_round_id_round"), + "question_to_theme", + "round", + ["round_id"], + ["id"], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + op.f("fk_question_to_theme_round_id_round"), + "question_to_theme", + type_="foreignkey", + ) + op.drop_column("question_to_theme", "round_id") + op.drop_constraint( + op.f("fk_question_theme_id_theme"), "question", type_="foreignkey" + ) + op.drop_column("question", "theme_id") + op.alter_column("game", "choice_user_id", existing_type=sa.BIGINT(), nullable=False) + op.alter_column("game", "active_user_id", existing_type=sa.BIGINT(), nullable=False) + # ### end Alembic commands ### diff --git a/app/core/database/migrations/versions/2025_04_23_1609-17651c57241b_.py b/app/core/database/migrations/versions/2025_04_23_1609-17651c57241b_.py new file mode 100644 index 0000000..f343cdb --- /dev/null +++ b/app/core/database/migrations/versions/2025_04_23_1609-17651c57241b_.py @@ -0,0 +1,31 @@ +"""empty message + +Revision ID: 17651c57241b +Revises: 6d7c9baf7b92 +Create Date: 2025-04-23 16:09:59.934549 + +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "17651c57241b" +down_revision: str | None = "ea84e7a02b20" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade(): + op.drop_constraint("pk_question_to_theme", "question_to_theme", type_="primary") + op.create_primary_key( + "pk_question_to_theme", "question_to_theme", ["round_id", "theme_id", "question_id"] + ) + + +def downgrade(): + op.drop_constraint("pk_question_to_theme", "question_to_theme", type_="primary") + op.create_primary_key( + "pk_question_to_theme", "question_to_theme", ["theme_id", "question_id"] + ) diff --git a/app/core/database/migrations/versions/2025_04_23_2211-a0f9c95c9a02_.py b/app/core/database/migrations/versions/2025_04_23_2211-a0f9c95c9a02_.py new file mode 100644 index 0000000..b158afe --- /dev/null +++ b/app/core/database/migrations/versions/2025_04_23_2211-a0f9c95c9a02_.py @@ -0,0 +1,39 @@ +"""empty message + +Revision ID: a0f9c95c9a02 +Revises: 17651c57241b +Create Date: 2025-04-23 22:11:50.138601 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a0f9c95c9a02" +down_revision: str | None = "17651c57241b" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "admin_model", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("password", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_admin_model")), + sa.UniqueConstraint("email", name=op.f("uq_admin_model_email")), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("admin_model") + # ### end Alembic commands ### diff --git a/app/core/database/sqlalchemy_base.py b/app/core/database/sqlalchemy_base.py index 28b0d83..92c6627 100644 --- a/app/core/database/sqlalchemy_base.py +++ b/app/core/database/sqlalchemy_base.py @@ -1,5 +1,14 @@ +from sqlalchemy import MetaData from sqlalchemy.orm import DeclarativeBase class BaseModel(DeclarativeBase): - pass \ No newline at end of file + metadata = MetaData( + naming_convention={ + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_N_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", + }, + ) diff --git a/app/core/manager.py b/app/core/manager.py index b1ad7e7..2142705 100644 --- a/app/core/manager.py +++ b/app/core/manager.py @@ -18,6 +18,10 @@ async def connect(self) -> None: self._channel = await self._connection.channel() self._queue = await self._channel.declare_queue(self._queue_name, durable=True) + async def close(self) -> None: + if self._connection: + await self._connection.close() + async def send(self, body: bytes) -> None: if self._channel is None: raise RuntimeError("RabbitMQManager is not connected") @@ -33,7 +37,3 @@ async def consume( if self._queue is None: raise RuntimeError("RabbitMQManager is not connected") await self._queue.consume(handler) - - async def close(self) -> None: - if self._connection: - await self._connection.close() diff --git a/app/core/session.py b/app/core/session.py new file mode 100644 index 0000000..c471eea --- /dev/null +++ b/app/core/session.py @@ -0,0 +1,13 @@ +from typing import TYPE_CHECKING + +from aiohttp_session import setup as aiohttp_setup_session +from aiohttp_session.cookie_storage import EncryptedCookieStorage +from cryptography import fernet + +if TYPE_CHECKING: + from app.app import Application + + +def setup_session(app: 'Application', key: str) -> None: + f_key = fernet.Fernet(key) + aiohttp_setup_session(app, EncryptedCookieStorage(f_key)) diff --git a/app/core/store.py b/app/core/store.py deleted file mode 100644 index 62350ca..0000000 --- a/app/core/store.py +++ /dev/null @@ -1,13 +0,0 @@ -import typing - -if typing.TYPE_CHECKING: - from app.app import Application - - -class Store: # noqa: B903 - def __init__(self, app: "Application"): - self.app = app - - -def setup_store(app: "Application") -> None: - app.store = Store(app) diff --git a/app/fixtures/__init__.py b/app/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/fixtures/data.json b/app/fixtures/data.json new file mode 100644 index 0000000..cc8bafc --- /dev/null +++ b/app/fixtures/data.json @@ -0,0 +1,224 @@ +[ + { + "model": "theme", + "fields": { + "title": "Наука", + "id": 1 + } + }, + { + "model": "theme", + "fields": { + "title": "География", + "id": 2 + } + }, + { + "model": "theme", + "fields": { + "title": "Литература", + "id": 3 + } + }, + { + "model": "theme", + "fields": { + "title": "Кино", + "id": 4 + } + }, + { + "model": "theme", + "fields": { + "title": "Технологии", + "id": 5 + } + }, + { + "model": "theme", + "fields": { + "title": "Музыка", + "id": 6 + } + }, + { + "model": "question", + "fields": { + "text": "Какой химический элемент обозначается символом \"O\"?", + "answer": "Кислород", + "hard_level": 1, + "theme_id": 1, + "id": 1 + } + }, + { + "model": "question", + "fields": { + "text": "Как называется наука, изучающая строение и свойства вещества?", + "answer": "Химия", + "hard_level": 2, + "theme_id": 1, + "id": 2 + } + }, + { + "model": "question", + "fields": { + "text": "Какой физик впервые сформулировал три закона движения?", + "answer": "Исаак Ньютон", + "hard_level": 3, + "theme_id": 1, + "id": 3 + } + }, + { + "model": "question", + "fields": { + "text": "Как называется самая большая страна в мире по площади?", + "answer": "Россия", + "hard_level": 1, + "theme_id": 2, + "id": 4 + } + }, + { + "model": "question", + "fields": { + "text": "Через какие два континента проходит Турция?", + "answer": "Азия и Европа", + "hard_level": 2, + "theme_id": 2, + "id": 5 + } + }, + { + "model": "question", + "fields": { + "text": "Какое море не имеет выхода к океану и полностью окружено сушей?", + "answer": "Каспийское море", + "hard_level": 3, + "theme_id": 2, + "id": 6 + } + }, + { + "model": "question", + "fields": { + "text": "Кто написал роман \"Война и мир\"?", + "answer": "Лев Толстой", + "hard_level": 1, + "theme_id": 3, + "id": 7 + } + }, + { + "model": "question", + "fields": { + "text": "Как называется произведение, начинающееся строкой: \"У лукоморья дуб зелёный...\"?", + "answer": "\"Руслан и Людмила\" — А.С. Пушкин", + "hard_level": 2, + "theme_id": 3, + "id": 8 + } + }, + { + "model": "question", + "fields": { + "text": "Кто автор романа \"Преступление и наказание\"?", + "answer": "Фёдор Достоевский", + "hard_level": 3, + "theme_id": 3, + "id": 9 + } + }, + { + "model": "question", + "fields": { + "text": "Как зовут волшебника, наставника Гарри Поттера?", + "answer": "Альбус Дамблдор", + "hard_level": 1, + "theme_id": 4, + "id": 10 + } + }, + { + "model": "question", + "fields": { + "text": "Какой фильм получил «Оскар» за лучший фильм в 1997 году и рассказывал о затонувшем лайнере?", + "answer": "Титаник", + "hard_level": 2, + "theme_id": 4, + "id": 11 + } + }, + { + "model": "question", + "fields": { + "text": "Кто сыграл главную роль в фильме \"Начало\" (Inception)?", + "answer": "Леонардо Ди Каприо", + "hard_level": 3, + "theme_id": 4, + "id": 12 + } + }, + { + "model": "question", + "fields": { + "text": "Что означает сокращение \"USB\"?", + "answer": "Universal Serial Bus", + "hard_level": 1, + "theme_id": 5, + "id": 13 + } + }, + { + "model": "question", + "fields": { + "text": "Как называется самая популярная операционная система от Microsoft?", + "answer": "Windows", + "hard_level": 2, + "theme_id": 5, + "id": 14 + } + }, + { + "model": "question", + "fields": { + "text": "Какой алгоритм хэширования используется в блокчейне Bitcoin?", + "answer": "SHA-256", + "hard_level": 3, + "theme_id": 5, + "id": 15 + } + }, + { + "model": "question", + "fields": { + "text": " Сколько струн у стандартной гитары?", + "answer": "Шесть", + "hard_level": 1, + "theme_id": 6, + "id": 16 + } + }, + { + "model": "question", + "fields": { + "text": "Какой композитор написал «Лунную сонату»?", + "answer": "Людвиг ван Бетховен", + "hard_level": 2, + "theme_id": 6, + "id": 17 + } + }, + { + "model": "question", + "fields": { + "text": "Как называется музыкальный стиль, зародившийся в США в 1970-х и характеризующийся рифмованным речитативом под бит?", + "answer": "Хип-хоп", + "hard_level": 3, + "theme_id": 6, + "id": 18 + } + } +] \ No newline at end of file diff --git a/app/fixtures/fixtures.py b/app/fixtures/fixtures.py new file mode 100644 index 0000000..cab16e5 --- /dev/null +++ b/app/fixtures/fixtures.py @@ -0,0 +1,223 @@ +import argparse +import asyncio +import contextlib +import importlib +import inspect +import json +import logging +import os +import sys +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + +import aiofiles +from sqlalchemy import delete, select +from typing_extensions import TypedDict + +from app.app import setup_app +from app.core.accessor import transaction +from app.core.database.database import BaseModel + +app = setup_app() +app.database.connect() + + +class FieldsDict(TypedDict): + pass + + +class FixtureItem(TypedDict): + model: str + fields: dict[str, Any] + + +MODEL_MAP: dict[str, type[BaseModel]] = {} + + +class DateTimeEncoder(json.JSONEncoder): + def default(self, obj: Any) -> Any: + if isinstance(obj, datetime): + return obj.isoformat() + return super().default(obj) + + +def find_db_files(start_dir: str) -> list[str]: + db_files: list[str] = [] + + for root, _dirs, files in os.walk(start_dir): + if "models.py" in files: + db_files.append(str(Path(root) / "models.py")) + + return db_files + + +def import_modules( + db_files: list[str], + start_dir: str, + logger: "logging.Logger", +) -> None: + for db_file in db_files: + rel_path: str = os.path.relpath(db_file, start_dir) + module_name: str = "app." + rel_path.replace(os.sep, ".")[:-3] + + try: + importlib.import_module(module_name) + except ImportError: + logger.exception("Ошибка импорта модуля %s", module_name) + + +def get_model_classes() -> dict[str, type[BaseModel]]: + model_classes: dict[str, type[BaseModel]] = {} + + for module_name, module in sys.modules.items(): + if module_name.startswith("app."): + for _name, obj in inspect.getmembers(module): + if ( + inspect.isclass(obj) + and issubclass(obj, BaseModel) + and obj is not BaseModel + ): + model_classes[obj.__tablename__] = obj + + return model_classes + + +async def dump_data( + logger: "logging.Logger", + model_map: dict[str, type[BaseModel]], + file_path: str, + models: list[str] | None = None, +) -> None: + + if models is None: + models = list(model_map.keys()) + + data: list[FixtureItem] = [] + + async with app.accessors.base_accessor.session() as session: + for model_name in models: + if model_name not in model_map: + logger.warning("Модель %s не найдена", model_name) + continue + + model_class: type[BaseModel] = model_map[model_name] + result = await session.execute(select(model_class)) + objects = result.scalars().all() + + for obj in objects: + fields: dict[str, Any] = { + col.name: getattr(obj, col.name) for col in obj.__table__.columns + } + data.append({"model": model_name, "fields": fields}) + + async with aiofiles.open(file_path, "w", encoding="utf-8") as f: + await f.write( + json.dumps(data, indent=4, ensure_ascii=False, cls=DateTimeEncoder), + ) + + logger.info("Данные успешно выгружены в %s", file_path) + + +async def load_data( + logger: "logging.Logger", + model_map: dict[str, type[BaseModel]], + file_path: str, + *, + clear_before: bool = False, +) -> None: + + async with aiofiles.open(file_path, encoding="utf-8") as f: + content = await f.read() + + data: list[FixtureItem] = json.loads(content) + + data_by_model: defaultdict[str, list[FixtureItem]] = defaultdict(list) + + for item in data: + model_name: str = item["model"] + if model_name in model_map: + item_fields: dict[str, Any] = item["fields"] + for key, value in item_fields.items(): + if isinstance(value, str) and value: + with contextlib.suppress(ValueError): + item_fields[key] = datetime.fromisoformat(value) + item["fields"] = item_fields + data_by_model[model_name].append(item) + else: + logger.warning("Модель %s не найдена в базе данных", model_name) + + async with transaction(app.accessors.base_accessor) as session: + if clear_before: + for model_class in model_map.values(): + await session.execute(delete(model_class)) + logger.info("Все таблицы очищены перед загрузкой") + + for model_name, items in data_by_model.items(): + model_cls: type[BaseModel] = model_map[model_name] + for item in items: + fields: dict[str, Any] = item["fields"] + obj: BaseModel = model_cls(**fields) + session.add(obj) + + logger.info("Данные успешно загружены из %s", file_path) + + +async def main() -> None: + parser: argparse.ArgumentParser = argparse.ArgumentParser( + description="Утилита для работы с данными базы", + ) + subparsers = parser.add_subparsers(dest="command") + + dump_parser: argparse.ArgumentParser = subparsers.add_parser( + "dump", + help="Выгрузить данные в JSON", + ) + dump_parser.add_argument("file_path", type=str, help="Путь к JSON-файлу") + dump_parser.add_argument( + "--models", + nargs="*", + type=str, + help="Список моделей (по умолчанию все)", + ) + + load_parser: argparse.ArgumentParser = subparsers.add_parser( + "load", + help="Загрузить данные из JSON", + ) + load_parser.add_argument("file_path", type=str, help="Путь к JSON-файлу") + load_parser.add_argument( + "--clear", + action="store_true", + help="Очистить базу перед загрузкой", + ) + + args: argparse.Namespace = parser.parse_args() + + logger = logging.getLogger(__name__) + + start_dir: str = ".." + db_files: list[str] = find_db_files(start_dir) + import_modules(db_files, start_dir, logger) + + model_map = get_model_classes() + + if args.command == "dump": + models: list[str] | None = args.models or None + await dump_data(logger, model_map, args.file_path, models) + elif args.command == "load": + await load_data(logger, model_map, args.file_path, clear_before=args.clear) + else: + logger.info("Команда не указана. Используйте --help для справки") + parser.print_help() + + await app.database.disconnect() + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + asyncio.run(main()) \ No newline at end of file diff --git a/app/poller/Dockerfile b/app/poller/Dockerfile index dfd71b2..dc23a35 100644 --- a/app/poller/Dockerfile +++ b/app/poller/Dockerfile @@ -9,5 +9,4 @@ ENV PATH="/project/.venv/bin:$PATH" COPY . . -WORKDIR /project CMD python -m app.poller.poller diff --git a/app/poller/poller.py b/app/poller/poller.py index e0a1b8f..47b8d0c 100644 --- a/app/poller/poller.py +++ b/app/poller/poller.py @@ -8,8 +8,7 @@ from app.core.manager import RabbitMQManager -async def get_updates(offset: int) -> dict[str, Any]: - url = f"https://api.telegram.org/bot{app.config.bot.token}/getUpdates" # поменяю +async def get_updates(url: str, offset: int) -> dict[str, Any]: async with aiohttp.ClientSession() as session: async with session.get(url, params={"offset": offset, "timeout": 30}) as resp: return cast(dict[str, Any], await resp.json()) @@ -24,11 +23,12 @@ async def poll_and_push() -> None: offset = 0 while True: - data = await get_updates(offset) + data = await get_updates(app.bot_api.build_method_url("getUpdates"), offset) for update in data.get("result", []): offset = update["update_id"] + 1 await rabbit.send(json.dumps(update).encode()) -setup_app() -asyncio.run(poll_and_push()) +if __name__ == "__main__": + setup_app() + asyncio.run(poll_and_push()) diff --git a/app/users/models.py b/app/users/models.py deleted file mode 100644 index c716161..0000000 --- a/app/users/models.py +++ /dev/null @@ -1,19 +0,0 @@ -from sqlalchemy import CheckConstraint, String -from sqlalchemy.orm import Mapped, mapped_column - -from app.core.database.mixins import IDMixin -from app.core.database.sqlalchemy_base import BaseModel - - -class TelegramUserModel(IDMixin, BaseModel): - __tablename__ = "telegram_user" - - __table_args__ = ( - CheckConstraint("win_count >= 0", name="win_count_non_negative"), - CheckConstraint("loss_count >= 0", name="loss_count_non_negative"), - ) - - username: Mapped[str] = mapped_column(String(64)) - score: Mapped[int] = mapped_column(default=0) - win_count: Mapped[int] = mapped_column(default=0) - loss_count: Mapped[int] = mapped_column(default=0) diff --git a/docker-compose.yml b/docker-compose.yml index b95c045..053d119 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,3 +46,16 @@ services: env_file: - .env restart: always + +# worker: +# build: +# context: . +# dockerfile: app/bot/Dockerfile +# depends_on: +# postgres: +# condition: service_healthy +# rabbitmq: +# condition: service_healthy +# env_file: +# - .env +# restart: always diff --git a/main.py b/main.py deleted file mode 100644 index 0688cf5..0000000 --- a/main.py +++ /dev/null @@ -1,6 +0,0 @@ -from aiohttp.web import run_app - -from app.app import setup_app - -if __name__ == "__main__": - run_app(setup_app()) diff --git a/pyproject.toml b/pyproject.toml index 7a093ec..fd4e7f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "webargs==8.6.0", "yarl==1.19.0", "aio-pika==9.5.5", + "aiofiles>=24.1.0", ] [dependency-groups] @@ -97,6 +98,7 @@ extend-ignore = [ "D1", "CPY001", "SIM117", + "SIM114", # По рекомендации https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules "W191", "E111", diff --git a/requirements.txt b/requirements.txt index ff3eb4a..20ca9c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +aiofiles==24.1.0 aiohttp==3.11.16 aiohttp_apispec==3.0.0b2 aiohttp_cors==0.8.1 diff --git a/uv.lock b/uv.lock index 0e86abb..0dccc32 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/cf/efa5581760bd08263bce8dbf943f32006b6dfd5bc120f43a26257281b546/aio_pika-9.5.5-py3-none-any.whl", hash = "sha256:94e0ac3666398d6a28b0c3b530c1febf4c6d4ececb345620727cfd7bfe1c02e0", size = 54257 }, ] +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896 }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -355,6 +364,7 @@ version = "0.0.1" source = { virtual = "." } dependencies = [ { name = "aio-pika" }, + { name = "aiofiles" }, { name = "aiohttp" }, { name = "aiohttp-apispec" }, { name = "aiohttp-cors" }, @@ -396,6 +406,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aio-pika", specifier = "==9.5.5" }, + { name = "aiofiles", specifier = ">=24.1.0" }, { name = "aiohttp", specifier = "==3.11.16" }, { name = "aiohttp-apispec", specifier = "==3.0.0b2" }, { name = "aiohttp-cors", specifier = "==0.8.1" }, From 86a32ccfb6579b65e57c308948c440dcfd1be980 Mon Sep 17 00:00:00 2001 From: Gray Advantage Date: Wed, 23 Apr 2025 23:34:54 +0300 Subject: [PATCH 4/6] feat: pain 2 --- app/admin/Dockerfile | 12 ++++++ app/admin/accessor.py | 86 +++++++++++++++++++++++++++++++++++++++++++ app/admin/mixins.py | 21 +++++++++++ app/admin/models.py | 19 ++++++++++ app/admin/routes.py | 18 +++++++++ app/admin/schemes.py | 61 ++++++++++++++++++++++++++++++ app/admin/utils.py | 49 ++++++++++++++++++++++++ app/admin/views.py | 67 +++++++++++++++++++++++++++++++++ 8 files changed, 333 insertions(+) create mode 100644 app/admin/Dockerfile create mode 100644 app/admin/accessor.py create mode 100644 app/admin/mixins.py create mode 100644 app/admin/models.py create mode 100644 app/admin/routes.py create mode 100644 app/admin/schemes.py create mode 100644 app/admin/utils.py create mode 100644 app/admin/views.py diff --git a/app/admin/Dockerfile b/app/admin/Dockerfile new file mode 100644 index 0000000..a7fb119 --- /dev/null +++ b/app/admin/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-alpine +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /project +COPY pyproject.toml uv.lock ./ + +RUN uv sync --compile-bytecode --no-cache --no-dev +ENV PATH="/project/.venv/bin:$PATH" + +COPY . . + +CMD python -m app.bot.views \ No newline at end of file diff --git a/app/admin/accessor.py b/app/admin/accessor.py new file mode 100644 index 0000000..178df7f --- /dev/null +++ b/app/admin/accessor.py @@ -0,0 +1,86 @@ +from typing import cast + +from sqlalchemy import insert, select +from sqlalchemy.orm import selectinload + +from app.admin.models import AdminModel +from app.bot.models import QuestionModel, ThemeModel +from app.core.accessor_base import BaseAccessor + + +class AdminAccessor(BaseAccessor): + async def connect(self, *args, **kwargs) -> None: + await self.create_admin( + email=self.app.config.admin.login, + password=self.app.config.admin.password, + ) + + async def get_by_email(self, email: str) -> AdminModel | None: + query = select(AdminModel).filter(AdminModel.email == email) + return await self.scalar(query) + + async def get_by_id(self, admin_id: int) -> AdminModel | None: + query = select(AdminModel).filter(AdminModel.id == admin_id) + return (await self.execute(query)).scalar_one_or_none() + + async def create_admin(self, email: str, password: str) -> AdminModel: + async with self.app.database.session() as session: + query = select(AdminModel).where(AdminModel.email == email) + existing = (await session.execute(query)).scalar_one_or_none() + + if existing: + return existing + + admin = AdminModel(email=email) + admin.set_password(password) + session.add(admin) + await session.commit() + return admin + + +class ThemeAccessor(BaseAccessor): + async def get_theme_by_id(self, theme_id: int) -> ThemeModel | None: + exp = ( + select(ThemeModel) + .where(ThemeModel.id == theme_id) + .options(selectinload(ThemeModel.questions)) + ) + return await self.scalar(exp) + + async def get_all_themes(self) -> list[ThemeModel]: + exp = select(ThemeModel).options(selectinload(ThemeModel.questions)) + return list(await self.scalars(exp)) + + async def get_all_questions(self) -> list[QuestionModel]: + exp = select(QuestionModel) + return list(await self.scalars(exp)) + + async def create_theme(self, title: str) -> ThemeModel: + exp = insert(ThemeModel).values(title=title).returning(ThemeModel) + return cast(ThemeModel, await self.scalar(exp)) + + async def get_question_by_id(self, question_id: int) -> QuestionModel | None: + exp = ( + select(QuestionModel) + .where(QuestionModel.id == question_id) + .options(selectinload(QuestionModel.theme)) + ) + return await self.scalar(exp) + + async def get_questions_by_theme(self, theme_id: int) -> list[QuestionModel]: + exp = ( + select(QuestionModel) + .where(QuestionModel.theme_id == theme_id) + .options(selectinload(QuestionModel.theme)) + ) + return list(await self.scalars(exp)) + + async def create_question( + self, text: str, answer: str, hard_level: int, theme_id: int + ) -> QuestionModel: + exp = ( + insert(QuestionModel) + .values(text=text, answer=answer, hard_level=hard_level, theme_id=theme_id) + .returning(QuestionModel) + ) + return cast(QuestionModel, await self.scalar(exp)) diff --git a/app/admin/mixins.py b/app/admin/mixins.py new file mode 100644 index 0000000..46c2658 --- /dev/null +++ b/app/admin/mixins.py @@ -0,0 +1,21 @@ +from aiohttp.web_exceptions import HTTPUnauthorized +from aiohttp_session import get_session +from app.app import app + + +class AuthRequiredMixin: + async def _iter(self): + session = await get_session(request=self.request) + admin_email = session.get("admin_email", None) + + if admin_email is None: + raise HTTPUnauthorized() + + admin = await app.accessors.admin_accessor.get_by_email(admin_email) + + if admin is None: + raise HTTPUnauthorized() + + self.request.admin = admin + + return await super()._iter() diff --git a/app/admin/models.py b/app/admin/models.py new file mode 100644 index 0000000..5018c02 --- /dev/null +++ b/app/admin/models.py @@ -0,0 +1,19 @@ +from hashlib import sha256 + +from sqlalchemy import Column, Integer, String + +from app.core.database.sqlalchemy_base import BaseModel + + +class AdminModel(BaseModel): + __tablename__ = "admin_model" + + id = Column(Integer, primary_key=True, autoincrement=True) + email = Column(String, nullable=False, unique=True) + password = Column(String, nullable=True) + + def check_password(self, password: str) -> bool: + return self.password == sha256(password.encode("utf-8")).hexdigest() + + def set_password(self, password: str) -> None: + self.password = sha256(password.encode("utf-8")).hexdigest() diff --git a/app/admin/routes.py b/app/admin/routes.py new file mode 100644 index 0000000..0440dab --- /dev/null +++ b/app/admin/routes.py @@ -0,0 +1,18 @@ +import typing + +if typing.TYPE_CHECKING: + from app.app import Application + + +def setup_routes(app: "Application"): + from app.admin.views import ( + AdminCurrentView, + AdminLoginView, + QuestionsView, + ThemesView, + ) + + app.router.add_view("/admin/current", AdminCurrentView) + app.router.add_view("/admin/login", AdminLoginView) + app.router.add_view("/admin/questions", QuestionsView) + app.router.add_view("/admin/themes", ThemesView) diff --git a/app/admin/schemes.py b/app/admin/schemes.py new file mode 100644 index 0000000..e1a3279 --- /dev/null +++ b/app/admin/schemes.py @@ -0,0 +1,61 @@ +from pydantic import BaseModel + + +class AdminSchema(BaseModel): + email: str + password: str + + class Config: + from_attributes = True + + +class AdminResponseSchema(BaseModel): + id: int + email: str + + class Config: + from_attributes = True + + +class OkResponseSchema(BaseModel): + status: str + data: dict + + class Config: + from_attributes = True + + +class ThemeSchema(BaseModel): + title: str + + class Config: + from_attributes = True + + +class ThemeResponseSchema(BaseModel): + id: int + title: str + + class Config: + from_attributes = True + + +class QuestionSchema(BaseModel): + text: str + answer: str + hard_level: int + theme_id: int + + class Config: + from_attributes = True + + +class QuestionResponseSchema(BaseModel): + id: int + text: str + answer: str + hard_level: int + theme_id: int + + class Config: + from_attributes = True diff --git a/app/admin/utils.py b/app/admin/utils.py new file mode 100644 index 0000000..c4ebc31 --- /dev/null +++ b/app/admin/utils.py @@ -0,0 +1,49 @@ +from functools import wraps + +from aiohttp.web import json_response as aiohttp_json_response +from aiohttp.web_response import Response +from pydantic import BaseModel, ValidationError + + +def json_response(data: BaseModel | None = None, status: str = "ok") -> Response: + data = {} if data is None else data.model_dump() + + return aiohttp_json_response( + data={ + "status": status, + "data": data, + }, + ) + + +def error_json_response( + http_status: int, + status: str | None = None, + message: str | None = None, + data: dict | None = None, +): + return aiohttp_json_response( + status=http_status, + data={ + "status": status or 400, + "message": message, + "data": data, + }, + ) + + +def validate_json(model: type[BaseModel]): + def decorator(handler): + @wraps(handler) + async def wrapper(self, *args, **kwargs): + try: + json_data = await self.request.json() + validated = model(**json_data) + self.request['data'] = validated + except ValidationError as e: + return error_json_response(400, message=str(e)) + + return await handler(self, *args, **kwargs) + + return wrapper + return decorator diff --git a/app/admin/views.py b/app/admin/views.py new file mode 100644 index 0000000..81c8701 --- /dev/null +++ b/app/admin/views.py @@ -0,0 +1,67 @@ +from http import HTTPStatus + +from aiohttp_apispec import response_schema +from aiohttp_session import new_session + +from app.admin.mixins import AuthRequiredMixin +from app.admin.schemes import ( + AdminResponseSchema, + AdminSchema, + OkResponseSchema, + QuestionResponseSchema, + ThemeResponseSchema, +) +from app.admin.utils import error_json_response, json_response, validate_json +from app.app import View, app + + +class AdminLoginView(View): + @validate_json(AdminSchema) + async def post(self): + data: AdminSchema = self.request["data"] + + admin = await app.accessors.admin_accessor.get_by_email(data.email) + if admin is None or not admin.check_password(data.password): + return error_json_response( + HTTPStatus.FORBIDDEN, + message="Incorrect email or password", + ) + + session = await new_session(request=self.request) + session["admin_email"] = data.email + + return json_response(AdminResponseSchema.model_validate(admin)) + + +class AdminCurrentView(AuthRequiredMixin, View): + async def get(self): + return json_response(AdminResponseSchema.model_validate(self.request.admin)) + + +class ThemesView(AuthRequiredMixin, View): + async def get(self): + themes = await app.accessors.theme_accessor.get_all_themes() + themes_data = [ + ThemeResponseSchema.model_validate(theme).model_dump() for theme in themes + ] + return json_response( + OkResponseSchema(status="ok", data={"themes": themes_data}) + ) + + +class QuestionsView(AuthRequiredMixin, View): + async def get(self): + theme_id = self.request.query.get("theme_id") + if theme_id: + questions = await app.accessors.theme_accessor.get_questions_by_theme( + int(theme_id) + ) + else: + questions = await app.accessors.theme_accessor.get_all_questions() + questions_data = [ + QuestionResponseSchema.model_validate(question).model_dump() + for question in questions + ] + return json_response( + OkResponseSchema(status="ok", data={"questions": questions_data}) + ) \ No newline at end of file From b491b35de56908707f2acb1bdc0ec0a917830239 Mon Sep 17 00:00:00 2001 From: Gray Advantage Date: Fri, 25 Apr 2025 04:57:23 +0300 Subject: [PATCH 5/6] feat: added full docker --- alembic.ini | 2 +- app/admin/Dockerfile | 2 +- app/bot/views.py | 4 +-- app/fixtures/fixtures.py | 7 ++++- docker-compose.yml | 59 +++++++++++++++++++++++++++++----------- requirements.txt | 3 -- 6 files changed, 53 insertions(+), 24 deletions(-) diff --git a/alembic.ini b/alembic.ini index 9ebafb9..b7d2fa7 100644 --- a/alembic.ini +++ b/alembic.ini @@ -1,5 +1,5 @@ [alembic] -script_location = .\app\core\database\migrations +script_location = app/core/database/migrations file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s prepend_sys_path = . diff --git a/app/admin/Dockerfile b/app/admin/Dockerfile index a7fb119..320aebd 100644 --- a/app/admin/Dockerfile +++ b/app/admin/Dockerfile @@ -9,4 +9,4 @@ ENV PATH="/project/.venv/bin:$PATH" COPY . . -CMD python -m app.bot.views \ No newline at end of file +CMD python -m app.admin.main \ No newline at end of file diff --git a/app/bot/views.py b/app/bot/views.py index b636419..3a77c95 100644 --- a/app/bot/views.py +++ b/app/bot/views.py @@ -79,7 +79,7 @@ async def summarize_the_results(chat: Chat, game_id: int) -> None: ) -@bot.connect_handler(commands=["start"]) +@bot.connect_handler(commands=["start", "приветик"]) async def start(message: Message) -> None: if message.chat.type == "private": await bot.send_message( @@ -104,7 +104,7 @@ async def start(message: Message) -> None: ) -@bot.connect_handler(commands=["stop"]) +@bot.connect_handler(commands=["stop", "пакетик"]) async def stop(message: Message) -> None: game = await app.accessors.game_accessor.get_active_game(message.chat) if game is None: diff --git a/app/fixtures/fixtures.py b/app/fixtures/fixtures.py index cab16e5..10a65c5 100644 --- a/app/fixtures/fixtures.py +++ b/app/fixtures/fixtures.py @@ -14,6 +14,7 @@ import aiofiles from sqlalchemy import delete, select +from sqlalchemy.exc import IntegrityError from typing_extensions import TypedDict from app.app import setup_app @@ -220,4 +221,8 @@ async def main() -> None: level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) - asyncio.run(main()) \ No newline at end of file + + try: + asyncio.run(main()) + except IntegrityError: + sys.exit(0) diff --git a/docker-compose.yml b/docker-compose.yml index 053d119..48bf253 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,10 +18,7 @@ services: command: -p ${DATABASE__PORT} rabbitmq: - image: rabbitmq:4.0.8-management-alpine # временно - ports: - - ${RABBITMQ__PORT}:${RABBITMQ__PORT} - - 15672:15672 # временно + image: rabbitmq:4.0.8-alpine env_file: - .env environment: @@ -34,6 +31,17 @@ services: retries: 10 restart: always + migrator: + build: + context: . + dockerfile: app/admin/Dockerfile + depends_on: + postgres: + condition: service_healthy + env_file: + - .env + command: sh -c "alembic upgrade head && python -m app.fixtures.fixtures load ./app/fixtures/data.json" + poller: build: context: . @@ -43,19 +51,38 @@ services: condition: service_healthy rabbitmq: condition: service_healthy + migrator: + condition: service_completed_successfully + env_file: + - .env + restart: always + + bot: + build: + context: . + dockerfile: app/bot/Dockerfile + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + migrator: + condition: service_completed_successfully env_file: - .env restart: always -# worker: -# build: -# context: . -# dockerfile: app/bot/Dockerfile -# depends_on: -# postgres: -# condition: service_healthy -# rabbitmq: -# condition: service_healthy -# env_file: -# - .env -# restart: always + admin: + build: + context: . + dockerfile: app/admin/Dockerfile + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + migrator: + condition: service_completed_successfully + env_file: + - .env + restart: always diff --git a/requirements.txt b/requirements.txt index 20ca9c8..6516db4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,9 +11,6 @@ greenlet==3.1.1 mypy==1.15.0 pydantic==2.11.3 pydantic_settings==2.8.1 -pytest==8.3.5 -pytest-aiohttp==1.1.0 -pytest-asyncio==0.26.0 PyYAML==6.0.2 pre-commit==4.2.0 ruff==0.11.5 From 9f2a6f689ca685f6b981438fd150a9e8d7f4da8f Mon Sep 17 00:00:00 2001 From: Gray Advantage Date: Fri, 25 Apr 2025 09:53:08 +0300 Subject: [PATCH 6/6] feat: added auto deploy (try 1) --- .github/workflows/deploy.yml | 49 ++++++++++++++++++++++ app/bot/views.py | 17 +++++--- prod.docker-compose.yml | 80 ++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 prod.docker-compose.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..6ac5f75 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,49 @@ +name: Build and Deploy + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Login to Docker Hub + run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + + - name: Build and Push bot + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/jeopardy-bot:latest -f app/bot/Dockerfile . + docker push ${{ secrets.DOCKER_USERNAME }}/jeopardy-bot:latest + + - name: Build and Push admin + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/jeopardy-admin:latest -f app/admin/Dockerfile . + docker push ${{ secrets.DOCKER_USERNAME }}/jeopardy-admin:latest + + - name: Build and Push poller + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/jeopardy-poller:latest -f app/poller/Dockerfile . + docker push ${{ secrets.DOCKER_USERNAME }}/jeopardy-poller:latest + + - name: Build and Push migrator + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/jeopardy-migrator:latest -f app/admin/Dockerfile . + docker push ${{ secrets.DOCKER_USERNAME }}/jeopardy-migrator:latest + + - name: Deploy on remote VPS + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USER }} + key: ${{ secrets.DEPLOY_KEY }} + script: | + cd jeopardybot + git pull + docker-compose -f prod.docker-compose.yml pull + docker-compose -f prod.docker-compose.yml up -d diff --git a/app/bot/views.py b/app/bot/views.py index 3a77c95..bc2a13f 100644 --- a/app/bot/views.py +++ b/app/bot/views.py @@ -113,10 +113,7 @@ async def stop(message: Message) -> None: await app.accessors.game_accessor.complete(game) if game.master_id == message.from_.id: - await bot.send_message( - message.chat, - "Игра завершена досрочно" - ) + await bot.send_message(message.chat, "Игра завершена досрочно") @bot.connect_callback_handler("start_game") @@ -134,9 +131,17 @@ async def start_game_handler(call: CallbackQuery) -> None: show_alert=True, ) return - await bot.answer_callback_query(call) - await app.accessors.game_accessor.next_round(call.message.chat) + users = await app.accessors.game_accessor.all_users(call.message.chat) + if len(users) == 0: + await bot.answer_callback_query( + call, + "Нужен хотя бы один игрок", + show_alert=True, + ) + return + + await app.accessors.game_accessor.next_round(call.message.chat) user = await app.accessors.game_accessor.set_choice_user( call.message.chat, choice(users), diff --git a/prod.docker-compose.yml b/prod.docker-compose.yml new file mode 100644 index 0000000..4f659b6 --- /dev/null +++ b/prod.docker-compose.yml @@ -0,0 +1,80 @@ +services: + postgres: + image: postgres:17.4-alpine + env_file: + - .env + environment: + - POSTGRES_USER=${DATABASE__USER} + - POSTGRES_PASSWORD=${DATABASE__PASSWORD} + - POSTGRES_DB=${DATABASE__DATABASE} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 2s + timeout: 5s + retries: 10 + restart: always + command: -p ${DATABASE__PORT} + + rabbitmq: + image: rabbitmq:4.0.8-alpine + env_file: + - .env + environment: + - RABBITMQ_DEFAULT_USER=${RABBITMQ__USER} + - RABBITMQ_DEFAULT_PASS=${RABBITMQ__PASSWORD} + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "ping"] + interval: 2s + timeout: 5s + retries: 10 + restart: always + + migrator: + image: grayadvantage/jeopardy-migrator:latest + depends_on: + postgres: + condition: service_healthy + env_file: + - .env + command: sh -c "alembic upgrade head && python -m app.fixtures.fixtures load ./app/fixtures/data.json" + + poller: + image: grayadvantage/jeopardy-poller:latest + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + migrator: + condition: service_completed_successfully + env_file: + - .env + restart: always + + bot: + image: grayadvantage/jeopardy-bot:latest + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + migrator: + condition: service_completed_successfully + env_file: + - .env + restart: always + + admin: + build: + context: . + dockerfile: grayadvantage/jeopardy-admin:latest + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + migrator: + condition: service_completed_successfully + env_file: + - .env + restart: always