From d4898869fa1fe907477f0c08c912f93a10a7e715 Mon Sep 17 00:00:00 2001 From: kolo Date: Wed, 31 Dec 2025 00:40:41 +0300 Subject: [PATCH] Initial commit --- alembic/env.py | 50 ++++++-- alembic/script.py.mako | 11 +- alembic/versions/409f04b7b544_initial.py | 39 ++++++ src/trudex/__main__.py | 4 +- src/trudex/application/bot/handlers.py | 1 - src/trudex/infrastructure/database/config.py | 19 ++- .../infrastructure/database/dao/user.py | 121 ++++++++++++++++++ src/trudex/infrastructure/database/models.py | 20 ++- src/trudex/infrastructure/di.py | 23 +++- src/trudex/infrastructure/utils/config.py | 5 +- 10 files changed, 258 insertions(+), 35 deletions(-) create mode 100644 alembic/versions/409f04b7b544_initial.py create mode 100644 src/trudex/infrastructure/database/dao/user.py diff --git a/alembic/env.py b/alembic/env.py index f7e137d..17ad9ee 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,23 +1,47 @@ +import asyncio from logging.config import fileConfig -from sqlalchemy import pool -from sqlalchemy.engine import Connection -from sqlalchemy.ext.asyncio import async_engine_from_config +from sqlalchemy import Connection +from sqlalchemy.ext.asyncio import create_async_engine from alembic import context +from trudex.infrastructure.database.models import Base +from trudex.infrastructure.utils.config import Config +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. config = context.config +# Interpret the config file for Python logging. +# This line sets up loggers basically. if config.config_file_name is not None: fileConfig(config.config_file_name) -target_metadata = None +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. +db_config = Config.from_toml("config.toml").database def run_migrations_offline() -> None: - url = config.get_main_option("sqlalchemy.url") + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ context.configure( - url=url, + url=db_config.url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, @@ -27,7 +51,7 @@ def run_migrations_offline() -> None: context.run_migrations() -def do_run_migrations(connection: Connection) -> None: +def do_run_migrations(connection: Connection): context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): @@ -35,11 +59,11 @@ def do_run_migrations(connection: Connection) -> None: async def run_async_migrations() -> None: - connectable = async_engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = create_async_engine(db_config.url) async with connectable.connect() as connection: await connection.run_sync(do_run_migrations) @@ -48,7 +72,7 @@ async def run_async_migrations() -> None: def run_migrations_online() -> None: - import asyncio + """Run migrations in 'online' mode.""" asyncio.run(run_async_migrations()) diff --git a/alembic/script.py.mako b/alembic/script.py.mako index a58301c..c812c15 100644 --- a/alembic/script.py.mako +++ b/alembic/script.py.mako @@ -1,19 +1,20 @@ -${message} +"""${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} -from typing import Sequence, Union +""" +from collections.abc import Sequence from alembic import op import sqlalchemy as sa ${imports if imports else ""} 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)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} def upgrade() -> None: diff --git a/alembic/versions/409f04b7b544_initial.py b/alembic/versions/409f04b7b544_initial.py new file mode 100644 index 0000000..35c409a --- /dev/null +++ b/alembic/versions/409f04b7b544_initial.py @@ -0,0 +1,39 @@ +"""initial + +Revision ID: 409f04b7b544 +Revises: +Create Date: 2025-12-31 00:38:10.367405 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = '409f04b7b544' +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('username', sa.String(length=32), nullable=True), + sa.Column('first_name', sa.String(length=64), nullable=False), + sa.Column('last_name', sa.String(length=64), nullable=True), + sa.Column('group', sa.Integer(), nullable=True), + sa.Column('is_admin', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('users') + # ### end Alembic commands ### diff --git a/src/trudex/__main__.py b/src/trudex/__main__.py index a42263d..a7daa55 100644 --- a/src/trudex/__main__.py +++ b/src/trudex/__main__.py @@ -3,13 +3,13 @@ import logging from aiogram import Bot, Dispatcher -from trudex.infrastructure.utils.config import AppConfig +from trudex.infrastructure.utils.config import Config async def main(): logging.basicConfig(level=logging.INFO) - config = AppConfig.from_toml() + config = Config.from_toml("config.toml") logging.info("Бот запущен") diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index e40f738..1e22eb6 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -1,4 +1,3 @@ from aiogram import Router - router = Router() diff --git a/src/trudex/infrastructure/database/config.py b/src/trudex/infrastructure/database/config.py index a8bd5f8..8b3bd0f 100644 --- a/src/trudex/infrastructure/database/config.py +++ b/src/trudex/infrastructure/database/config.py @@ -1,9 +1,14 @@ -from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import (AsyncSession, async_sessionmaker, + create_async_engine) -def create_engine(database_url: str) -> AsyncEngine: - return create_async_engine(database_url, echo=False) - - -def create_session_maker(engine: AsyncEngine) -> async_sessionmaker: - return async_sessionmaker(engine, expire_on_commit=False) +def new_session_maker(db_url: str) -> async_sessionmaker[AsyncSession]: + engine = create_async_engine( + db_url, + pool_size=15, + max_overflow=15, + connect_args={ + "timeout": 5, + }, + ) + return async_sessionmaker(engine, class_=AsyncSession, autoflush=False, expire_on_commit=False) diff --git a/src/trudex/infrastructure/database/dao/user.py b/src/trudex/infrastructure/database/dao/user.py new file mode 100644 index 0000000..0606151 --- /dev/null +++ b/src/trudex/infrastructure/database/dao/user.py @@ -0,0 +1,121 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from trudex.infrastructure.database.models import User + + +class UserDAO: + def __init__(self, session: AsyncSession) -> None: + self.session: AsyncSession = session + + async def get_by_id(self, user_id: int) -> User | None: + result = await self.session.execute( + select(User).where(User.id == user_id) + ) + return result.scalar_one_or_none() + + async def get_all(self) -> list[User]: + result = await self.session.execute(select(User)) + return list(result.scalars().all()) + + async def get_by_group(self, group: int) -> list[User]: + result = await self.session.execute( + select(User).where(User.group == group) + ) + return list(result.scalars().all()) + + async def get_admins(self) -> list[User]: + result = await self.session.execute( + select(User).where(User.is_admin == True) + ) + return list(result.scalars().all()) + + async def create( + self, + user_id: int, + first_name: str, + username: str | None = None, + last_name: str | None = None, + group: int | None = None, + is_admin: bool = False, + ) -> User: + user = User( + id=user_id, + username=username, + first_name=first_name, + last_name=last_name, + group=group, + is_admin=is_admin, + ) + self.session.add(user) + await self.session.flush() + return user + + async def update( + self, + user_id: int, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + group: int | None = None, + is_admin: bool | None = None, + ) -> User | None: + user = await self.get_by_id(user_id) + if not user: + return None + + if username is not None: + user.username = username + if first_name is not None: + user.first_name = first_name + if last_name is not None: + user.last_name = last_name + if group is not None: + user.group = group + if is_admin is not None: + user.is_admin = is_admin + + await self.session.flush() + return user + + async def delete(self, user_id: int) -> bool: + user = await self.get_by_id(user_id) + if not user: + return False + + await self.session.delete(user) + await self.session.flush() + return True + + async def upsert( + self, + user_id: int, + first_name: str, + username: str | None = None, + last_name: str | None = None, + group: int | None = None, + is_admin: bool = False, + ) -> User: + user = await self.get_by_id(user_id) + if user: + if username is not None: + user.username = username + if first_name is not None: + user.first_name = first_name + if last_name is not None: + user.last_name = last_name + if group is not None: + user.group = group + if is_admin is not None: + user.is_admin = is_admin + await self.session.flush() + return user + + return await self.create( + user_id=user_id, + username=username, + first_name=first_name, + last_name=last_name, + group=group, + is_admin=is_admin, + ) diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index fa2b68a..3e067e7 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -1,5 +1,23 @@ -from sqlalchemy.orm import DeclarativeBase +from datetime import datetime +from typing import final + +from sqlalchemy import BigInteger, CheckConstraint, String, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass + + +@final +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + username: Mapped[str | None] = mapped_column(String(32)) + first_name: Mapped[str] = mapped_column(String(64)) + last_name: Mapped[str | None] = mapped_column(String(64)) + group: Mapped[int | None] = mapped_column(CheckConstraint("group >= 1000 AND group <= 9999")) + is_admin: Mapped[bool] = mapped_column(default=False) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py index 741c289..b674407 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/trudex/infrastructure/di.py @@ -1,9 +1,24 @@ -from dishka import Provider, Scope +from collections.abc import AsyncIterable + +from dishka import Provider, Scope, provide +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from trudex.infrastructure.database.config import new_session_maker +from trudex.infrastructure.utils.config import Config class DatabaseProvider(Provider): - scope = Scope.APP + @provide(scope=Scope.APP) + def get_session_maker(self, config: Config) -> async_sessionmaker[AsyncSession]: + return new_session_maker(config.database.url) + @provide(scope=Scope.REQUEST) + async def get_session( + self, session_maker: async_sessionmaker[AsyncSession] + ) -> AsyncIterable[AsyncSession]: + async with session_maker() as session: + yield session -class InfrastructureProvider(Provider): - scope = Scope.APP + @provide(scope=Scope.REQUEST) + async def get_users_dao(self, session: AsyncSession) -> UsersDAO: + return UsersDAO(session) diff --git a/src/trudex/infrastructure/utils/config.py b/src/trudex/infrastructure/utils/config.py index ffc1010..8cc30a7 100644 --- a/src/trudex/infrastructure/utils/config.py +++ b/src/trudex/infrastructure/utils/config.py @@ -1,6 +1,7 @@ import tomllib from dataclasses import dataclass from pathlib import Path +from typing import Self @dataclass @@ -22,12 +23,12 @@ class DatabaseConfig: @dataclass -class AppConfig: +class Config: bot: BotConfig database: DatabaseConfig @classmethod - def from_toml(cls, path: str | Path = "config.toml") -> "AppConfig": + def from_toml(cls, path: str | Path) -> Self: with open(path, "rb") as f: data: dict[str, dict[str, str]] = tomllib.load(f)