diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 1c101f8..052ebee 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -6,3 +6,14 @@ services: dockerfile: ./docker/Dockerfile command: sh -c "uv run --active --script ./scripts/pybabel.py compile && uv run ./src/main.py" restart: always + + database: + container_name: ${POSTGRES_HOST} + image: postgres:15 + ports: + - 127.0.0.1:${POSTGRES_PORT}:${POSTGRES_PORT} + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASS} + POSTGRES_DB: ${POSTGRES_NAME} diff --git a/pyproject.toml b/pyproject.toml index fc99779..f159cc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.13,<3.14" dependencies = [ "aiogram>=3.22.0", + "asyncpg>=0.30.0", "babel>=2.17.0", "loguru>=0.7.3", "pydantic>=2.11.10", diff --git a/src/bot/__init__.py b/src/bot/__init__.py index a8b91d3..ae97876 100644 --- a/src/bot/__init__.py +++ b/src/bot/__init__.py @@ -4,7 +4,7 @@ __all__ = ["start_bot"] from aiogram import Bot, Dispatcher from loguru import logger as loguru_logger from redis.asyncio.client import Redis -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from .handlers import include_routers, registry from .middlewares import connect_middlewares @@ -14,7 +14,7 @@ from .middlewares import connect_middlewares async def start_bot( bot_token: str, redis_client: Redis, - database_session: AsyncSession, + database_session: async_sessionmaker[AsyncSession], ) -> None: """ Start Telegram bot. diff --git a/src/config/utils.py b/src/config/utils.py index bf0e313..8f21dea 100644 --- a/src/config/utils.py +++ b/src/config/utils.py @@ -43,6 +43,20 @@ class Settings(BaseSettings): Defaults to "INFO". """ + postgres_host: str = Field(frozen=True, default="postgres") + postgres_port: int = Field(frozen=True, default=5432) + postgres_user: str = Field(frozen=True) + postgres_pass: str = Field(frozen=True) + postgres_name: str = Field(frozen=True) + + @property + def database_url(self): + return ( + f"postgresql+asyncpg://{self.postgres_user}:" + f"{self.postgres_pass}@{self.postgres_host}:" + f"{self.postgres_port}/{self.postgres_name}" + ) + bot_token: str = Field(frozen=True) listen_logging: bool = Field( diff --git a/src/database/__init__.py b/src/database/__init__.py index 1727ba2..4c8fbb8 100644 --- a/src/database/__init__.py +++ b/src/database/__init__.py @@ -1,4 +1,4 @@ -__all__ = ["session"] +__all__ = ["create_session", "create_db", "drop_db"] -from .session import session +from .session import create_db, create_session, drop_db diff --git a/src/database/models.py b/src/database/models.py index c855580..cad8f11 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -1 +1,107 @@ -# TODO: Add database models. +from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import ( + DeclarativeBase, + Mapped, + mapped_column, + relationship, +) + + +class Base(DeclarativeBase): + updated: Mapped[DateTime] = mapped_column( + DateTime, + default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + created: Mapped[DateTime] = mapped_column( + DateTime, + default=func.now(), + nullable=False, + ) + + +class UniversityMember(Base): + __tablename__ = "university_members" + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + autoincrement=True, + ) + + name: Mapped[str] = mapped_column( + String(128), + nullable=False, + ) + + tg_users = relationship( + "TelegramUser", + back_populates="university_member", + cascade="all, delete-orphan", + ) + + __mapper_args__ = { + "polymorphic_identity": "university_member", + "polymorphic_on": "type", + } + + type: Mapped[str] = mapped_column(String(50)) + + +class Student(UniversityMember): + __tablename__ = "students" + + id: Mapped[int] = mapped_column( + Integer, + ForeignKey("university_members.id"), + primary_key=True, + ) + + student_id: Mapped[int] = mapped_column( + Integer, + unique=True, + nullable=False, + ) + + group_id: Mapped[int] = mapped_column( + Integer, + nullable=True, + ) + + __mapper_args__ = { + "polymorphic_identity": "student", + } + + +class TelegramUser(Base): + __tablename__ = "telegram_users" + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + autoincrement=False, + ) + + username: Mapped[str] = mapped_column( + String(64), + nullable=True, + ) + + lang: Mapped[str] = mapped_column( + String(2), + default="ru", + nullable=False, + ) + + university_member_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("university_members.id"), + nullable=True, + ) + + university_member = relationship( + "UniversityMember", + back_populates="tg_users", + ) diff --git a/src/database/session.py b/src/database/session.py index abdcd2d..4fd389c 100644 --- a/src/database/session.py +++ b/src/database/session.py @@ -1,4 +1,40 @@ -from sqlalchemy.ext.asyncio import AsyncSession +# from loguru import logger as loguru_logger +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) -# TODO: Add database session. -session: AsyncSession = ... +from .models import Base + +engine: AsyncEngine + + +def create_session( + url: str, engine_echo: bool = True +) -> async_sessionmaker[AsyncSession]: + global engine + engine = create_async_engine(url=url, echo=engine_echo) + + session = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + ) + + return session + + +async def create_db(): + global engine + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def drop_db(): + global engine + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) diff --git a/src/main.py b/src/main.py index 824ed22..c738f16 100644 --- a/src/main.py +++ b/src/main.py @@ -2,7 +2,7 @@ import asyncio from bot import start_bot from config import configure_logger, settings -from database import session +from database import create_db, create_session from redis_client import client @@ -17,6 +17,13 @@ def main() -> None: listen_logging=settings.listen_logging, ) + session = create_session( + url=settings.database_url, engine_echo=True + ) + + # FIXME: Add argument for create/drop db. + asyncio.run(create_db()) + asyncio.run( start_bot( bot_token=settings.bot_token, diff --git a/uv.lock b/uv.lock index 96fe2b2..f9e25b2 100644 --- a/uv.lock +++ b/uv.lock @@ -92,6 +92,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "asyncpg" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746, upload-time = "2024-10-20T00:30:41.127Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373, upload-time = "2024-10-20T00:29:55.165Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745, upload-time = "2024-10-20T00:29:57.14Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103, upload-time = "2024-10-20T00:29:58.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471, upload-time = "2024-10-20T00:30:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253, upload-time = "2024-10-20T00:30:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720, upload-time = "2024-10-20T00:30:04.501Z" }, + { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404, upload-time = "2024-10-20T00:30:06.537Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623, upload-time = "2024-10-20T00:30:09.024Z" }, +] + [[package]] name = "attrs" version = "25.4.0" @@ -466,6 +482,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aiogram" }, + { name = "asyncpg" }, { name = "babel" }, { name = "loguru" }, { name = "pydantic" }, @@ -483,6 +500,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiogram", specifier = ">=3.22.0" }, + { name = "asyncpg", specifier = ">=0.30.0" }, { name = "babel", specifier = ">=2.17.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "pydantic", specifier = ">=2.11.10" },