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]