This commit is contained in:
2025-08-03 20:41:56 +03:00
commit 5f51ac7d22
43 changed files with 1441 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
from core.db_config import db_settings
from .models.user import *
from .providers import DataAsyncProvider
db_conn = DataAsyncProvider(db_settings.db_url)
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+99
View File
@@ -0,0 +1,99 @@
import asyncio
import sys
from logging.config import fileConfig
from os.path import abspath, dirname
from alembic import context
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncEngine
from core.db_config import db_settings
from db.models.base import Base
sys.path.insert(0, dirname(dirname(dirname(abspath(__file__)))))
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the core file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", db_settings.db_url)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
target_metadata = Base.metadata
# other values from the core, defined by the needs of env.py,
# can be acquired:
# my_important_option = core.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"},
compare_type=True
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection,
target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
async 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 = AsyncEngine(
engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
future=True,
)
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
+28
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"}
@@ -0,0 +1,32 @@
"""init
Revision ID: 01c1151f5b52
Revises:
Create Date: 2025-08-03 19:37:01.619206
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '01c1151f5b52'
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! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -0,0 +1,57 @@
"""add_users
Revision ID: be9893939a59
Revises: 01c1151f5b52
Create Date: 2025-08-03 19:39:48.172257
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from db import Password
# revision identifiers, used by Alembic.
revision: str = 'be9893939a59'
down_revision: Union[str, Sequence[str], None] = '01c1151f5b52'
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.BigInteger(), nullable=False),
sa.Column('username', sa.String(length=50), nullable=False),
sa.Column('password', Password(length=156), nullable=False),
sa.Column('access_level',
sa.Enum('user', 'support', 'moderator', 'administrator', name='accesslevel'),
nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=True)
op.create_table('user_sessions',
sa.Column('token', sa.UUID(), nullable=False),
sa.Column('user_id', sa.BigInteger(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('token')
)
op.create_index(op.f('ix_user_sessions_token'), 'user_sessions', ['token'], unique=False)
op.create_index(op.f('ix_user_sessions_user_id'), 'user_sessions', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_user_sessions_user_id'), table_name='user_sessions')
op.drop_index(op.f('ix_user_sessions_token'), table_name='user_sessions')
op.drop_table('user_sessions')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###
View File
+5
View File
@@ -0,0 +1,5 @@
"""Base."""
from sqlalchemy.orm import declarative_base
Base = declarative_base()
+50
View File
@@ -0,0 +1,50 @@
import uuid
import bcrypt
from sqlalchemy import Column, ForeignKey, String, func
from sqlalchemy.orm import relationship, validates
from sqlalchemy.sql.expression import select
from sqlalchemy.sql.sqltypes import UUID, BigInteger, Enum, DateTime
from helpers.admin.enums import AccessLevel
from .base import Base
from ..types.fields import Password
class User(Base):
__tablename__ = "users"
id = Column(BigInteger, primary_key=True, index=True, unique=True, nullable=False)
username = Column(String(50), nullable=False, unique=True)
password = Column(Password(length=156), nullable=False)
access_level = Column(Enum(AccessLevel))
sessions = relationship("UserSession", back_populates="user")
@validates("password")
def _validate_password(self, key, password):
return getattr(type(self), key).type.validator(password)
def verify_password(self, password):
return bcrypt.checkpw(password.encode(), self.password.hash.encode())
class UserSession(Base):
__tablename__ = "user_sessions"
token = Column(
UUID(as_uuid=True),
default=uuid.uuid4,
nullable=False,
index=True,
primary_key=True,
)
user_id = Column(BigInteger, ForeignKey("users.id"), index=True, nullable=False)
user = relationship("User", back_populates="sessions")
created_at = Column(DateTime, default=func.now(), nullable=False)
@classmethod
def _filter_session_by_user_id(cls, user_id: int):
query = select(cls).where(cls.user_id == user_id)
return query
+34
View File
@@ -0,0 +1,34 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.sql.expression import text
from helpers.logging import logger
class DataAsyncProvider:
def __init__(self, db_url: str):
self.url = db_url
self.engine = create_async_engine(self.url, echo=False, future=True)
self.async_session_factory = async_sessionmaker(
self.engine, class_=AsyncSession, expire_on_commit=False
)
async def get_async_session(self) -> AsyncGenerator[AsyncSession, None]:
async with self.async_session_factory() as session:
yield session
@asynccontextmanager
async def async_session_manager(self) -> AsyncGenerator[AsyncSession, None]:
async with self.async_session_factory() as session:
yield session
async def is_connected(self) -> bool:
try:
async with self.async_session_manager() as session:
await session.execute(text("SELECT 1"))
return True
except Exception as ex:
logger.exception(ex)
return False
View File
+63
View File
@@ -0,0 +1,63 @@
import json
from sqlalchemy.types import TypeDecorator, Text, String
from crypto.password import PasswordHash
class JSONEncodedDict(TypeDecorator):
impl = Text
def process_bind_param(self, value, dialect):
if isinstance(value, dict | list | tuple):
return json.dumps(value, separators=(",", ":"))
elif isinstance(value, str):
json.loads(value)
return value
def process_result_value(self, value, dialect):
if value is not None:
value = json.loads(value)
return value
class Password(TypeDecorator):
"""Allows storing and retrieving password hashes using PasswordHash."""
impl = String
def __init__(self, rounds=12, **kwds):
self.rounds = rounds
super(Password, self).__init__(**kwds)
def process_bind_param(self, value, dialect):
"""Ensure the value is a PasswordHash and then return its hash."""
if value is not None:
return self._convert(value).hash
return None
def process_result_value(self, value, dialect):
"""Convert the hash to a PasswordHash, if it's non-NULL."""
if value is not None:
return PasswordHash(value, rounds=self.rounds)
return None
def validator(self, password):
"""Provides a validator/converter for @validates usage."""
return self._convert(password)
def _convert(self, value):
"""Returns a PasswordHash from the given string.
PasswordHash instances or None values will return unchanged.
Strings will be hashed and the resulting PasswordHash returned.
Any other input will result in a TypeError.
"""
if isinstance(value, PasswordHash):
return value
elif isinstance(value, str):
return PasswordHash.new(value, self.rounds)
elif value is not None:
raise TypeError("Cannot convert {} to a PasswordHash".format(type(value)))
return None