Explorar o código

Add database module

Librellium hai 2 meses
pai
achega
38c5195f08

+ 147 - 0
alembic.ini

@@ -0,0 +1,147 @@
+# A generic, single database configuration.
+
+[alembic]
+# path to migration scripts.
+# this is typically a path given in POSIX (e.g. forward slashes)
+# format, relative to the token %(here)s which refers to the location of this
+# ini file
+script_location = %(here)s/anonflow/database/migrations
+
+# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
+# Uncomment the line below if you want the files to be prepended with date and time
+# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
+# for all available tokens
+# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
+
+# sys.path path, will be prepended to sys.path if present.
+# defaults to the current working directory.  for multiple paths, the path separator
+# is defined by "path_separator" below.
+prepend_sys_path = .
+
+
+# timezone to use when rendering the date within the migration file
+# as well as the filename.
+# If specified, requires the tzdata library which can be installed by adding
+# `alembic[tz]` to the pip requirements.
+# string value is passed to ZoneInfo()
+# leave blank for localtime
+# timezone =
+
+# max length of characters to apply to the "slug" field
+# truncate_slug_length = 40
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+# set to 'true' to allow .pyc and .pyo files without
+# a source .py file to be detected as revisions in the
+# versions/ directory
+# sourceless = false
+
+# version location specification; This defaults
+# to <script_location>/versions.  When using multiple version
+# directories, initial revisions must be specified with --version-path.
+# The path separator used here should be the separator specified by "path_separator"
+# below.
+# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
+
+# path_separator; This indicates what character is used to split lists of file
+# paths, including version_locations and prepend_sys_path within configparser
+# files such as alembic.ini.
+# The default rendered in new alembic.ini files is "os", which uses os.pathsep
+# to provide os-dependent path splitting.
+#
+# Note that in order to support legacy alembic.ini files, this default does NOT
+# take place if path_separator is not present in alembic.ini.  If this
+# option is omitted entirely, fallback logic is as follows:
+#
+# 1. Parsing of the version_locations option falls back to using the legacy
+#    "version_path_separator" key, which if absent then falls back to the legacy
+#    behavior of splitting on spaces and/or commas.
+# 2. Parsing of the prepend_sys_path option falls back to the legacy
+#    behavior of splitting on spaces, commas, or colons.
+#
+# Valid values for path_separator are:
+#
+# path_separator = :
+# path_separator = ;
+# path_separator = space
+# path_separator = newline
+#
+# Use os.pathsep. Default configuration used for new projects.
+path_separator = os
+
+# set to 'true' to search source files recursively
+# in each "version_locations" directory
+# new in Alembic version 1.10
+# recursive_version_locations = false
+
+# the output encoding used when revision files
+# are written from script.py.mako
+# output_encoding = utf-8
+
+# database URL.  This is consumed by the user-maintained env.py script only.
+# other means of configuring database URLs may be customized within the env.py
+# file.
+sqlalchemy.url = driver://user:pass@localhost/dbname
+
+
+[post_write_hooks]
+# post_write_hooks defines scripts or Python functions that are run
+# on newly generated revision scripts.  See the documentation for further
+# detail and examples
+
+# format using "black" - use the console_scripts runner, against the "black" entrypoint
+# hooks = black
+# black.type = console_scripts
+# black.entrypoint = black
+# black.options = -l 79 REVISION_SCRIPT_FILENAME
+
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# hooks = ruff
+# ruff.type = module
+# ruff.module = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Alternatively, use the exec runner to execute a binary found on your PATH
+# hooks = ruff
+# ruff.type = exec
+# ruff.executable = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Logging configuration.  This is also consumed by the user-maintained
+# env.py script only.
+[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

+ 5 - 0
anonflow/database/__init__.py

@@ -0,0 +1,5 @@
+from .database import Database
+from .orm import User
+from .repositories import UserRepository
+
+__all__ = ["Database", "User", "UserRepository"]

+ 3 - 0
anonflow/database/base.py

@@ -0,0 +1,3 @@
+from sqlalchemy.orm import declarative_base
+
+Base = declarative_base()

+ 30 - 0
anonflow/database/database.py

@@ -0,0 +1,30 @@
+from pathlib import Path
+
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+from sqlalchemy.orm import sessionmaker
+
+from anonflow.paths import DATABASE_FILEPATH
+
+from .base import Base
+from .repositories import UserRepository
+
+
+class Database:
+    def __init__(self, filepath: Path = DATABASE_FILEPATH, echo: bool = False):
+        self.url = f"sqlite+aiosqlite:///{filepath}"
+        self._engine = create_async_engine(self.url, echo=echo, future=True) #type: ignore
+        self._session_maker = sessionmaker( # type: ignore
+            self._engine, expire_on_commit=False, class_=AsyncSession # type: ignore
+        )
+
+        self.users = UserRepository(self)
+
+    async def close(self):
+        await self._engine.dispose()
+
+    def get_session(self):
+        return self._session_maker()
+
+    async def init(self):
+        async with self._engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)

+ 1 - 0
anonflow/database/migrations/README

@@ -0,0 +1 @@
+Generic single-database configuration.

+ 87 - 0
anonflow/database/migrations/env.py

@@ -0,0 +1,87 @@
+import sys
+from logging.config import fileConfig
+from pathlib import Path
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+sys.path.append(str(Path(__file__).parent.parent.parent))
+
+from anonflow import paths
+from anonflow.database.models import Base
+
+# 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 = 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.
+config.set_main_option(
+    "sqlalchemy.url",
+    f"sqlite:///{paths.DATABASE_FILEPATH}"
+)
+
+
+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 run_migrations_online() -> None:
+    """Run migrations in 'online' mode.
+
+    In this scenario we need to create an Engine
+    and associate a connection with the context.
+
+    """
+    connectable = engine_from_config(
+        config.get_section(config.config_ini_section, {}),
+        prefix="sqlalchemy.",
+        poolclass=pool.NullPool,
+    )
+
+    with connectable.connect() as connection:
+        context.configure(
+            connection=connection, target_metadata=target_metadata
+        )
+
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 28 - 0
anonflow/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, 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"}

+ 11 - 0
anonflow/database/orm.py

@@ -0,0 +1,11 @@
+from sqlalchemy import Boolean, Column, Integer, String
+
+from .base import Base
+
+
+class User(Base):
+    __tablename__ = "users"
+
+    chat_id = Column(Integer, primary_key=True)
+    is_blocked = Column(Boolean, default=False)
+    language = Column(String(2), default="ru")

+ 3 - 0
anonflow/database/repositories/__init__.py

@@ -0,0 +1,3 @@
+from .user import UserRepository
+
+__all__ = ["UserRepository"]

+ 50 - 0
anonflow/database/repositories/user.py

@@ -0,0 +1,50 @@
+import logging
+
+from aiogram.types import ChatIdUnion
+from sqlalchemy import exists
+from sqlalchemy.future import select
+
+from anonflow.database.orm import User
+
+
+class UserRepository:
+    def __init__(self, db):
+        self._logger = logging.getLogger(__name__)
+
+        self._database = db
+
+    async def add(self, chat_id: ChatIdUnion):
+        async with self._database.get_session() as session: # type: ignore
+            user = User(chat_id=chat_id)
+            session.add(user)
+            await session.commit()
+
+    async def block(self, chat_id: ChatIdUnion):
+        async with self._database.get_session() as session: # type: ignore
+            user = await self.get(chat_id)
+            if user:
+                user.is_blocked = True
+                await session.commit()
+
+    async def get(self, chat_id: ChatIdUnion):
+        if await self.has(chat_id):
+            self._logger.warning("User chat_id=%s already exists.", chat_id)
+            return
+
+        async with self._database.get_session() as session: # type: ignore
+            result = await session.execute(select(User).where(User.chat_id == chat_id))
+            return result.scalar_one_or_none()
+
+    async def has(self, chat_id: ChatIdUnion):
+        async with self._database.get_session() as session:
+            result = await session.execute(
+                select(exists().where(User.chat_id == chat_id))
+            )
+            return result.scalar()
+
+    async def unblock(self, chat_id: ChatIdUnion):
+        async with self._database.get_session() as session: # type: ignore
+            user = await self.get(chat_id)
+            if user:
+                user.is_blocked = False
+                await session.commit()