diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index 4f70ed2..0000000 --- a/alembic.ini +++ /dev/null @@ -1,116 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -script_location = api/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. -prepend_sys_path = . - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the python>=3.9 or backports.zoneinfo library. -# Any required deps can 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 migrations/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 "version_path_separator" below. -# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions - -# version path separator; As mentioned above, this is the character used to split -# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. -# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. -# Valid values for version_path_separator are: -# -# version_path_separator = : -# version_path_separator = ; -# version_path_separator = space -version_path_separator = os # Use os.pathsep. Default configuration used for new projects. - -# 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 - -sqlalchemy.url = postgresql://demo_user:user_pass@db:5432/serviceman_db - - -[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 exec runner, execute a binary -# hooks = ruff -# ruff.type = exec -# ruff.executable = %(here)s/.venv/bin/ruff -# ruff.options = --fix REVISION_SCRIPT_FILENAME - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -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 diff --git a/api/app.py b/api/app.py index 574f82b..6eb0a91 100644 --- a/api/app.py +++ b/api/app.py @@ -1,12 +1,45 @@ -from fastapi import FastAPI +from collections.abc import AsyncIterable -from api.routers.user import router as user_router +from fastapi import FastAPI +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from api.depends import ( # new_unit_of_work,; new_user_repository,; new_user_service, + create_engine, + create_session_maker, + new_session, +) +from api.models import BaseModel + + +async def lifespan(app: FastAPI) -> AsyncIterable[None]: + engine = app.dependency_overrides[AsyncEngine]() + + async with engine.begin() as conn: + await conn.run_sync(BaseModel.metadata.create_all) + + yield + + async with engine.begin() as conn: + await conn.run_sync(BaseModel.metadata.drop_all) + + +def init_dependencies(app: FastAPI) -> None: + app.dependency_overrides[AsyncEngine] = create_engine + app.dependency_overrides[async_sessionmaker[AsyncSession]] = create_session_maker + app.dependency_overrides[AsyncSession] = new_session + # app.dependency_overrides[UserRepository] = new_user_repository + # app.dependency_overrides[UnitOfWork] = new_unit_of_work + # app.dependency_overrides[UserService] = new_user_service def create_app() -> FastAPI: - app = FastAPI() + app = FastAPI( + lifespan=lifespan, + ) - app.include_router(user_router) + # app.include_router(user_router) + + init_dependencies(app) return app diff --git a/api/config.py b/api/config.py index c987da5..c945de9 100644 --- a/api/config.py +++ b/api/config.py @@ -1,25 +1,20 @@ import os +from dataclasses import dataclass +from functools import lru_cache import yaml # type: ignore -from pydantic_settings import BaseSettings - -with open(os.getenv("CONFIG_PATH", "./config/api_config.yml")) as f: - config_data: dict = yaml.safe_load(f) - -if os.getenv("INDOCKER"): - config_data["db"]["host"] = "db" - config_data["db"]["port"] = 5432 -class DBSettings(BaseSettings): - pg_user: str = config_data["db"]["user"] - pg_pass: str = config_data["db"]["password"] - pg_host: str = config_data["db"]["host"] - pg_port: int = config_data["db"]["port"] - pg_db: str = config_data["db"]["database"] +@dataclass(frozen=True) +class DBSettings: + pg_user: str + pg_pass: str + pg_host: str + pg_port: int + pg_db: str @property - def get_db_url(self) -> str: + def db_url(self) -> str: return "postgresql+asyncpg://{}:{}@{}:{}/{}".format( self.pg_user, self.pg_pass, @@ -29,9 +24,24 @@ class DBSettings(BaseSettings): ) -settings = DBSettings() +@dataclass(frozen=True) +class RedisSettings: + redis_host: str + redis_port: int +@dataclass(frozen=True) +class Settings: + db: DBSettings + redis: RedisSettings + + +@lru_cache def get_settings(): - print(id(settings)) - return settings + with open(os.getenv("CONFIG_PATH", "./config/api_config.yml")) as f: + config_data: dict = yaml.safe_load(f) + + return Settings( + db=DBSettings(**config_data["db"]), + redis=RedisSettings(**config_data["redis"]), + ) diff --git a/api/depends.py b/api/depends.py new file mode 100644 index 0000000..5801a34 --- /dev/null +++ b/api/depends.py @@ -0,0 +1,92 @@ +from collections.abc import AsyncIterable, Callable +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from api.config import get_settings + + +class Stub: + """ + This class is used to prevent fastapi from digging into + real dependencies attributes detecting them as request data + + So instead of + `interactor: Annotated[Interactor, Depends()]` + Write + `interactor: Annotated[Interactor, Depends(Stub(Interactor))]` + + And then you can declare how to create it: + `app.dependency_overrids[Interactor] = some_real_factory` + + """ + + def __init__(self, dependency: Callable, **kwargs): + self._dependency = dependency + self._kwargs = kwargs + + def __call__(self): + raise NotImplementedError + + def __eq__(self, other) -> bool: + if isinstance(other, Stub): + return self._dependency == other._dependency and self._kwargs == other._kwargs + else: + if not self._kwargs: + return self._dependency == other + return False + + def __hash__(self): + if not self._kwargs: + return hash(self._dependency) + serial = ( + self._dependency, + *self._kwargs.items(), + ) + return hash(serial) + + +# def new_user_repository( +# session: Annotated[AsyncSession, Depends(Stub(AsyncSession))], +# ) -> UserRepository: +# return SqlalchemyUserRepository(session) +# +# +# def new_unit_of_work( +# session: Annotated[AsyncSession, Depends(Stub(AsyncSession))], +# ) -> UnitOfWork: +# return SqlalchemyUnitOfWork(session) +# + + +def create_engine() -> AsyncEngine: + return create_async_engine(url=get_settings().db.db_url) + + +def create_session_maker( + engine: Annotated[AsyncEngine, Depends(Stub(AsyncEngine))], +) -> async_sessionmaker[AsyncSession]: + return async_sessionmaker(engine, expire_on_commit=False) + + +async def new_session( + session_maker: Annotated[ + async_sessionmaker[AsyncSession], + Depends(Stub(async_sessionmaker[AsyncSession])), + ], +) -> AsyncIterable[AsyncSession]: + async with session_maker() as session: + yield session + + +# def new_user_service( +# uow: Annotated[UnitOfWork, Depends()], +# user_repository: Annotated[UserRepository, Depends()], +# ) -> UserService: +# return UserService(uow, user_repository) diff --git a/api/di.py b/api/di.py deleted file mode 100644 index d461934..0000000 --- a/api/di.py +++ /dev/null @@ -1,29 +0,0 @@ -from sqlalchemy.ext.asyncio import (AsyncSession, async_sessionmaker, - create_async_engine) - -from api.config import get_settings -from api.services.user import UserService -from api.uow.uow_base import UnitOfWork - -async_engine = create_async_engine( - url=get_settings().get_db_url, - echo=False, -) - -async_session_factory = async_sessionmaker( - async_engine, - class_=AsyncSession, - expire_on_commit=False, -) - -uow = UnitOfWork( - session_factory=async_session_factory, -) - -user_service = UserService( - uow=uow, -) - - -def get_user_service(): - return user_service diff --git a/api/migrations/README b/api/migrations/README deleted file mode 100644 index 2500aa1..0000000 --- a/api/migrations/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. diff --git a/api/migrations/env.py b/api/migrations/env.py deleted file mode 100644 index 8049e3c..0000000 --- a/api/migrations/env.py +++ /dev/null @@ -1,76 +0,0 @@ -from logging.config import fileConfig - -from alembic import context -from sqlalchemy import engine_from_config, pool - -import api.models as models - -# 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 = models.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. - - -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() diff --git a/api/migrations/script.py.mako b/api/migrations/script.py.mako deleted file mode 100644 index fbc4b07..0000000 --- a/api/migrations/script.py.mako +++ /dev/null @@ -1,26 +0,0 @@ -"""${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, 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: - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - ${downgrades if downgrades else "pass"} diff --git a/api/migrations/versions/629b2e73e311_initial.py b/api/migrations/versions/629b2e73e311_initial.py deleted file mode 100644 index 9638a71..0000000 --- a/api/migrations/versions/629b2e73e311_initial.py +++ /dev/null @@ -1,23 +0,0 @@ -"""initial - -Revision ID: 629b2e73e311 -Revises: -Create Date: 2024-03-12 00:58:28.851188 - -""" - -from collections.abc import Sequence - -# revision identifiers, used by Alembic. -revision: str = "629b2e73e311" -down_revision: str | None = None -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - pass - - -def downgrade() -> None: - pass diff --git a/api/migrations/versions/e98840064cab_first.py b/api/migrations/versions/e98840064cab_first.py deleted file mode 100644 index d0c55e5..0000000 --- a/api/migrations/versions/e98840064cab_first.py +++ /dev/null @@ -1,43 +0,0 @@ -"""First - -Revision ID: e98840064cab -Revises: 629b2e73e311 -Create Date: 2024-03-12 01:00:58.640179 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "e98840064cab" -down_revision: str | None = "629b2e73e311" -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("first_name", sa.String(), nullable=False), - sa.Column("mid_name", sa.String(), nullable=False), - sa.Column("last_name", sa.String(), nullable=False), - sa.Column("email", sa.String(), nullable=False), - sa.Column("telegram_id", sa.String(), nullable=False), - sa.Column("hashed_password", sa.String(), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("id", sa.UUID(), nullable=False), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("email"), - sa.UniqueConstraint("telegram_id"), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("users") - # ### end Alembic commands ### diff --git a/api/models/__init__.py b/api/models/__init__.py index 9d19859..099086c 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -1,7 +1,9 @@ -from .base_model import Base +from .base_model import BaseModel +from .refers_model import RefersModel from .user_model import UserModel __all__ = ( - "Base", + "BaseModel", "UserModel", + "RefersModel", ) diff --git a/api/models/base_model.py b/api/models/base_model.py index 203f0b5..ebba932 100644 --- a/api/models/base_model.py +++ b/api/models/base_model.py @@ -4,11 +4,10 @@ from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column -class Base(DeclarativeBase): +class BaseModel(DeclarativeBase): __abstract__ = True id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, - default=uuid.uuid4, ) diff --git a/api/models/refers_model.py b/api/models/refers_model.py new file mode 100644 index 0000000..f2a5b42 --- /dev/null +++ b/api/models/refers_model.py @@ -0,0 +1,18 @@ +from uuid import UUID + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models import BaseModel + + +class RefersModel(BaseModel): + __tablename__ = "refers" + + referer: Mapped[UUID] = mapped_column(ForeignKey("user.id")) + referals: Mapped[list["UserModel"]] = relationship( + "UserModel", + backref="refers", + lazy="selectin", + ) + is_active: Mapped[bool] diff --git a/api/models/user_model.py b/api/models/user_model.py index 6830a25..cf9db50 100644 --- a/api/models/user_model.py +++ b/api/models/user_model.py @@ -1,25 +1,11 @@ from sqlalchemy.orm import Mapped, mapped_column -from . import Base +from api.models import BaseModel -class UserModel(Base): - __tablename__ = "users" - - first_name: Mapped[str] - mid_name: Mapped[str] - last_name: Mapped[str] +class UserModel(BaseModel): + __tablename__ = "user" + name: Mapped[str] email: Mapped[str] = mapped_column(unique=True) - telegram_id: Mapped[str] = mapped_column(unique=True) - hashed_password: Mapped[str] - is_active: Mapped[bool] = mapped_column(default=False) - - def __repr__(self): - return ( - f"" - ) diff --git a/api/repositories/__init__.py b/api/repositories/__init__.py index a447006..e69de29 100644 --- a/api/repositories/__init__.py +++ b/api/repositories/__init__.py @@ -1,3 +0,0 @@ -from .user import UserRepository - -__all__ = ("UserRepository",) diff --git a/api/repositories/user.py b/api/repositories/user.py deleted file mode 100644 index 0ea87fd..0000000 --- a/api/repositories/user.py +++ /dev/null @@ -1,33 +0,0 @@ -from sqlalchemy import insert, select -from sqlalchemy.ext.asyncio.session import AsyncSession - -from api.models import UserModel -from api.schemas.user_schema import UserReadDTO, UserWriteDTO - - -class UserRepository: - def __init__(self, session: AsyncSession): - self.session = session - - async def add_one( - self, - data: UserWriteDTO, - ): - stmt = insert(UserModel).values(**data.model_dump()) - res = await self.session.execute(stmt) - return UserReadDTO.model_validate(res.scalar_one()) - - async def find_all(self): - stmt = select(UserModel) - res = await self.session.execute(stmt) - res = [UserReadDTO.model_validate(row) for row in res.scalars().all()] - return res - - async def find_one(self, filter: dict): - return - - async def update_one(self, filter: dict, data: dict): - return - - async def delete_one(self, filter: dict): - return diff --git a/api/routers/user.py b/api/routers/user.py deleted file mode 100644 index 00c923a..0000000 --- a/api/routers/user.py +++ /dev/null @@ -1,19 +0,0 @@ -from fastapi import APIRouter, Depends - -from api.di import get_user_service -from api.schemas import UserReadDTO -from api.services import UserService - -router = APIRouter() - - -@router.get("/users", response_model=list[UserReadDTO]) -async def get_user_list( - user_service: UserService = Depends(get_user_service), -) -> list[UserReadDTO]: - return await user_service.get_all_users() - - -@router.get("/status") -def get_status(): - return {"status": "OK"} diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py index 4b99d40..e69de29 100644 --- a/api/schemas/__init__.py +++ b/api/schemas/__init__.py @@ -1,10 +0,0 @@ -from .base_schema import BaseDTO, ReadDTO, WriteDTO -from .user_schema import UserReadDTO, UserWriteDTO - -__all__ = ( - "BaseDTO", - "WriteDTO", - "ReadDTO", - "UserWriteDTO", - "UserReadDTO", -) diff --git a/api/schemas/base_schema.py b/api/schemas/base_schema.py deleted file mode 100644 index bec3169..0000000 --- a/api/schemas/base_schema.py +++ /dev/null @@ -1,16 +0,0 @@ -from uuid import UUID - -from pydantic import BaseModel - - -class BaseDTO(BaseModel): - class Config: - from_attributes = True - - -class WriteDTO(BaseDTO): - pass - - -class ReadDTO(WriteDTO): - id: UUID diff --git a/api/schemas/refers_schema.py b/api/schemas/refers_schema.py new file mode 100644 index 0000000..3fe569b --- /dev/null +++ b/api/schemas/refers_schema.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + + +@dataclass(frozen=True) +class LifetimeRequestDTO: + days: int = 0 + hours: int = 0 + minutes: int = 30 + + +@dataclass(frozen=True) +class ReferRequestDTO: + lifetime: LifetimeRequestDTO + + +@dataclass(frozen=True) +class RefererResponseDTO: + name: str + email: str + + +@dataclass(frozen=True) +class ReferResponseDTO: + refer_id: UUID + expire_at: datetime + referer: RefererResponseDTO diff --git a/api/schemas/token_schema.py b/api/schemas/token_schema.py new file mode 100644 index 0000000..a4d8831 --- /dev/null +++ b/api/schemas/token_schema.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TokenDTO: + access_token: str + token_type: str diff --git a/api/schemas/user_schema.py b/api/schemas/user_schema.py index b59324b..8a1a39f 100644 --- a/api/schemas/user_schema.py +++ b/api/schemas/user_schema.py @@ -1,9 +1,9 @@ -from . import ReadDTO, WriteDTO +from dataclasses import dataclass + +# from uuid import UUID -class UserWriteDTO(WriteDTO): +@dataclass(frozen=True) +class UserRequestDTO: name: str - - -class UserReadDTO(ReadDTO, UserWriteDTO): - pass + email: str diff --git a/api/services/user.py b/api/services/user.py deleted file mode 100644 index 1032247..0000000 --- a/api/services/user.py +++ /dev/null @@ -1,12 +0,0 @@ -from api.uow.uow_base import UnitOfWork - - -class UserService: - def __init__(self, uow: UnitOfWork): - self.uow = uow - - async def get_all_users(self): - async with self.uow: - res = await self.uow.users.find_all() - - return res diff --git a/api/uow/uow_base.py b/api/uow/uow_base.py deleted file mode 100644 index a7bdd63..0000000 --- a/api/uow/uow_base.py +++ /dev/null @@ -1,20 +0,0 @@ -from api.repositories import UserRepository - - -class UnitOfWork: - def __init__(self, session_factory): - self.session_factory = session_factory - - async def __aenter__(self): - self.session = self.session_factory() - self.users = UserRepository(self.session) - - async def __aexit__(self, *args): - await self.session.rollback() - await self.session.close() - - async def commit(self): - await self.session.commit() - - async def rollback(self): - await self.session.rollback() diff --git a/docker-compose.yml b/docker-compose.yml index 1c36a92..1b84ef5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,7 +44,7 @@ services: - ./api:/usr/src/service_man/api - ./alembic.ini:/usr/src/service_man/alembic.ini - command: /bin/bash -c 'cd /usr/src/service_man && alembic upgrade head && poetry run uvicorn api.app:create_app --host 0.0.0.0 --reload --factory' + command: /bin/bash -c 'cd /usr/src/service_man && poetry run uvicorn api.app:create_app --host 0.0.0.0 --reload --factory' # bot: # container_name: bot