Init project

This commit is contained in:
2026-01-15 09:47:43 +03:00
commit 3e515c66ec
59 changed files with 5101 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
from .cli import get_cli
from .migrator import Migrator
__all__ = (
# cli
"get_cli",
# migrator
"Migrator",
)

View File

@@ -0,0 +1,75 @@
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from birthday_pool_bot.settings import Settings
from .migrator import Migrator
def callback(ctx: typer.Context):
ctx.obj = ctx.obj or {}
settings = ctx.obj["settings"]
ctx.obj["migrator"] = Migrator(settings=settings.repository)
def show_migrations_list(ctx: typer.Context):
migrator: Migrator = ctx.obj["migrator"]
table = Table("ID")
for migration_id in migrator.get_migrations():
table.add_row(migration_id)
Console().print(table)
def apply_migrations(ctx: typer.Context):
migrator: Migrator = ctx.obj["migrator"]
migrator.migrate()
def rollback_migrations(
ctx: typer.Context,
revision: Optional[str] = typer.Argument(
None,
help="Revision id or relative revision (`-1`, `-2`)",
),
):
migrator: Migrator = ctx.obj["migrator"]
migrator.rollback(migration_id=revision)
def squash_migrations(ctx: typer.Context):
migrator: Migrator = ctx.obj["migrator"]
migrator.squash_migrations(new_message="init")
def create_migration(
ctx: typer.Context,
message: Optional[str] = typer.Option(
None,
"-m", "--message",
help="Migration short message",
),
):
migrator: Migrator = ctx.obj["migrator"]
migrator.create_migration(message=message)
def get_cli() -> typer.Typer:
cli = typer.Typer(name="Migration")
cli.callback()(callback)
cli.command(name="apply")(apply_migrations)
cli.command(name="rollback")(rollback_migrations)
cli.command(name="create")(create_migration)
cli.command(name="squash")(squash_migrations)
cli.command(name="list")(show_migrations_list)
return cli

View File

@@ -0,0 +1,93 @@
import asyncio
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
from birthday_pool_bot.repositories.tables import User
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = User.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,146 @@
import importlib.util
import pathlib
from types import ModuleType
from typing import Generator, Iterable
from alembic import command as alembic_command
from alembic.config import Config as AlembicConfig
from birthday_pool_bot.repositories import RepositorySettings
class Migrator:
MIGRATION_FILENAME_TEMPLATE = (
"%%(year)d_%%(month)02d_%%(day)02d_%%(hour)02dh%%(minute)02dm%%(second)02ds_%%(slug)s"
)
def __init__(self, settings: RepositorySettings):
self._settings = settings
self._alembic_config = self._generate_alembic_config()
def _generate_alembic_config(self) -> AlembicConfig:
migrations_path = self.get_migrations_directory_path()
config = AlembicConfig()
config.set_main_option("script_location", str(migrations_path))
config.set_main_option("file_template", self.MIGRATION_FILENAME_TEMPLATE)
config.set_main_option("sqlalchemy.url", str(self._settings.dsn).replace("%", "%%"))
return config
def squash_migrations(
self,
new_migration_id: str | None = None,
**kwargs,
) -> str:
new_message = kwargs.get("new_message", None)
if new_migration_id is None:
last_migration_id = self.get_latest_migration()
new_migration_id = last_migration_id
self.rollback(migration_id="base")
self._delete_all_migrations()
new_migration_id = self.create_migration(
migration_id=new_migration_id,
message=new_message,
)
return new_migration_id
def migrate(self, migration_id: str | None = None, **kwargs) -> str | None:
alembic_revision = migration_id or "head"
alembic_command.upgrade(config=self._alembic_config, revision=alembic_revision)
return self.get_current_migration()
def rollback(self, migration_id: str | None = None, **kwargs) -> str | None:
alembic_revision = migration_id or "-1"
alembic_command.downgrade(config=self._alembic_config, revision=alembic_revision)
return self.get_current_migration()
def get_current_migration(self) -> str | None:
return None
def create_migration(
self,
migration_id: str | None = None,
**kwargs,
) -> str:
message = kwargs.get("message", None)
alembic_command.revision(
self._alembic_config,
message=message,
rev_id=migration_id,
autogenerate=True,
)
last_migration_id = self.get_latest_migration()
if last_migration_id is None:
raise RuntimeError("Migration not created")
return last_migration_id
def get_latest_migration(self) -> str | None:
last_migration_id = None
for migration_id in self.get_migrations():
last_migration_id = migration_id
return last_migration_id
def get_migrations(self) -> Generator[str, None, None]:
migrations_modules = self._get_migrations_modules()
sorted_migrations_modules = self._sort_migrations_modules(modules=migrations_modules)
for migration_module in sorted_migrations_modules:
yield migration_module.revision
def _get_migrations_modules(self) -> Iterable[ModuleType]:
migrations_path = self.get_migrations_directory_path()
versions_path = migrations_path / "versions"
for index, migration_module_path in enumerate(versions_path.glob("*.py")):
migration_module_name = f"migration_{index:08}"
migration_module_spec = importlib.util.spec_from_file_location(
name=migration_module_name,
location=migration_module_path,
)
if migration_module_spec is None or migration_module_spec.loader is None:
raise ValueError(f"Cannot load migration module '{migration_module_path}'")
migration_module = importlib.util.module_from_spec(spec=migration_module_spec)
migration_module_spec.loader.exec_module(migration_module)
yield migration_module
def _sort_migrations_modules(self, modules: Iterable[ModuleType]) -> list[ModuleType]:
next_module_map = {}
first_module = None
for module in modules:
if module.down_revision is None:
if first_module is not None:
raise ValueError((
"Found multiple first migrations: "
f"'{module.revision}' and '{first_module.revision}'"
))
first_module = module
else:
next_module_map[module.down_revision] = module
if first_module is None:
raise ValueError("Doesn't found first migration")
current_module = first_module
sorted_modules = [current_module]
while next_module_map:
current_module = next_module_map.pop(current_module.revision)
sorted_modules.append(current_module)
return sorted_modules
def _delete_all_migrations(self):
migrations_path = self.get_migrations_directory_path()
versions_path = migrations_path / "versions"
for migration_file_path in versions_path.glob("*.py"):
migration_file_path.unlink()
def get_migrations_directory_path(self) -> pathlib.Path:
directory_path = pathlib.Path(__file__).parent
return directory_path

View File

@@ -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, Sequence[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"}

View File

@@ -0,0 +1,99 @@
"""init
Revision ID: c1060d90df61
Revises:
Create Date: 2026-01-13 10:06:43.435312
"""
from typing import Sequence, Union
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c1060d90df61"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table("users",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("birthday", sa.Date(), nullable=True),
sa.Column("phone", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("telegram_id", sa.Integer(), nullable=True),
sa.Column("gift_payment_data", sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("id"),
sa.UniqueConstraint("phone", name="uq__users__phone"),
sa.UniqueConstraint("telegram_id", name="uq__users__telegram_id",)
)
op.create_index("ix__users__phone", "users", ["phone"], unique=False)
op.create_index("ix__users__telegram_id", "users", ["telegram_id"], unique=False)
op.create_table("pools",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("owner_id", sa.Uuid(), nullable=False),
sa.Column("birthday_user_id", sa.Uuid(), nullable=False),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("payment_data", sa.JSON(), nullable=False),
sa.CheckConstraint(
"owner_id <> birthday_user_id",
name="ck__pools__owner_not_birthday_user",
),
sa.ForeignKeyConstraint(["birthday_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("id"),
)
op.create_index("ix__pools__birthday_user_id", "pools", ["birthday_user_id"], unique=False)
op.create_index("ix__pools__owner_id", "pools", ["owner_id"], unique=False)
op.create_table("subscriptions",
sa.Column("from_user_id", sa.Uuid(), nullable=False),
sa.Column("to_user_id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("pool_id", sa.Uuid(), nullable=True),
sa.CheckConstraint(
"from_user_id <> to_user_id",
name="ck__subscriptions__from_user_not_to_user",
),
sa.ForeignKeyConstraint(["from_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["pool_id"], ["pools.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["to_user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("from_user_id", "to_user_id"),
sa.UniqueConstraint("from_user_id", "to_user_id", name="uq__subscriptions__from_to_user"),
)
op.create_index("ix__subscriptions__from_user_id", "subscriptions", ["from_user_id"], unique=False)
op.create_index(
"ix__subscriptions__from_user_id__to_user_id",
"subscriptions",
["from_user_id", "to_user_id"],
unique=False,
)
op.create_index("ix__subscriptions__name", "subscriptions", ["name"], unique=False)
op.create_index("ix__subscriptions__pool_id", "subscriptions", ["pool_id"], unique=False)
op.create_index("ix__subscriptions__to_user_id", "subscriptions", ["to_user_id"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("ix__subscriptions__to_user_id", table_name="subscriptions")
op.drop_index("ix__subscriptions__pool_id", table_name="subscriptions")
op.drop_index("ix__subscriptions__name", table_name="subscriptions")
op.drop_index("ix__subscriptions__from_user_id__to_user_id", table_name="subscriptions")
op.drop_index("ix__subscriptions__from_user_id", table_name="subscriptions")
op.drop_table("subscriptions")
op.drop_index("ix__pools__owner_id", table_name="pools")
op.drop_index("ix__pools__birthday_user_id", table_name="pools")
op.drop_table("pools")
op.drop_index("ix__users__telegram_id", table_name="users")
op.drop_index("ix__users__phone", table_name="users")
op.drop_table("users")
# ### end Alembic commands ###