diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100755 index 0000000..f87292a --- /dev/null +++ b/backend/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/app/migration + +# 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 /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 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100755 index 0000000..7f53093 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,62 @@ +from typing import Literal, List + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + COMPANY_NAME: str + + MODE: Literal["DEV", "TEST", "PROD"] + LOG_LEVEL: Literal["ERROR", "WARNING", "INFO", "DEBUG"] + + HOST: str + PORT: int + WORKERS: int + URL: str + + CORS_ORIGINS: List[str] = ["*"] + CORS_HEADERS: List[str] = ["*"] + CORS_METHODS: List[str] = ["*"] + + SECRET_KEY: str + ALGORITHM: str + ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 + REFRESH_TOKEN_EXPIRE_DAYS: int = 30 + + SMTP_SERVER: str + SMTP_PORT: int + SMTP_EMAIL: str + SMTP_PASS: str + + DB_HOST: str + DB_PORT: int + DB_PASS: str + DB_USER: str + DB_NAME: str + + @property + def DATABASE_URL(self): + return f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASS}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + + REDIS_HOST: str = "localhost" + REDIS_PORT: int = 6397 + REDIS_PASS: str = "" + REDIS_DB: int = 0 + + @property + def REDIS_URL(self): + return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + + RMQ_HOST: str + RMQ_USER: str + RMQ_PASS: str + RMQ_PORT: int + + @property + def RABBITMQ_URL(self) -> str: + return f"amqp://{self.RMQ_USER}:{self.RMQ_PASS}@{self.RMQ_HOST}:{self.RMQ_PORT}//" + + model_config = SettingsConfigDict(env_file=".env", extra="allow") + + +settings: Settings = Settings() \ No newline at end of file diff --git a/backend/app/constants.py b/backend/app/constants.py new file mode 100755 index 0000000..5e8c751 --- /dev/null +++ b/backend/app/constants.py @@ -0,0 +1,9 @@ +from enum import Enum + +DB_NAMING_CONVENTION = { + "ix": "%(column_0_label)s_idx", + "uq": "%(table_name)s_%(column_0_name)s_key", + "ck": "%(table_name)s_%(constraint_name)s_check", + "fk": "%(table_name)s_%(column_0_name)s_fkey", + "pk": "%(table_name)s_pkey", +} diff --git a/backend/app/dao.py b/backend/app/dao.py new file mode 100755 index 0000000..76f90d4 --- /dev/null +++ b/backend/app/dao.py @@ -0,0 +1,133 @@ +from typing import TypeVar, Generic, Optional, List, Union, Dict, Any +import logging + +from sqlalchemy import delete, insert, select, update, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError +from pydantic import BaseModel + +from app.database import Base + +log = logging.getLogger(__name__) + +ModelType = TypeVar("ModelType", bound=Base) +CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) + +class BaseDAO(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): + model = None + + @classmethod + async def find_one_or_none(cls, session: AsyncSession, *filter, **filter_by) -> Optional[ModelType]: + stmt = select(cls.model).filter(*filter).filter_by(**filter_by) + result = await session.execute(stmt) + return result.scalars().one_or_none() + + + @classmethod + async def find_all( + cls, + session: AsyncSession, + offset: Optional[int], + limit: Optional[int], + *filter, + **filter_by + ) -> List[ModelType]: + stmt = select(cls.model).filter(*filter).filter_by(**filter_by) + + if offset is not None: + stmt = stmt.offset(offset) + if limit is not None: + stmt = stmt.limit(limit) + + result = await session.execute(stmt) + return result.scalars().all() + + + @classmethod + async def add( + cls, + session: AsyncSession, + obj_in: Union[CreateSchemaType, Dict[str, Any]] + ) -> Optional[ModelType]: + if isinstance(obj_in, dict): + create_data = obj_in + else: + create_data = obj_in.model_dump(exclude_unset=True) + + try: + stmt = insert(cls.model).values(**create_data).returning(cls.model) + result = await session.execute(stmt) + return result.scalars().first() + except (SQLAlchemyError, Exception) as ex: + if isinstance(ex, SQLAlchemyError): + msg = "Database Exc: Cannot insert data into table" + elif isinstance(ex, Exception): + msg = "Unknown Exc: Cannot insert data into table" + + log.error(msg, extra={"table": cls.model.__tablename__}, exc_info=True) + + + @classmethod + async def delete(cls, session: AsyncSession, *filter, **filter_by) -> None: + stmt = delete(cls.model).filter(*filter).filter_by(**filter_by) + await session.execute(stmt) + + + @classmethod + async def update( + cls, + session: AsyncSession, + *where, + obj_in: Union[UpdateSchemaType, Dict[str, Any]] + ) -> Optional[ModelType]: + if isinstance(obj_in, Dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + + stmt = update(cls.model).where(*where).values(update_data).returning(cls.model) + + result = await session.execute(stmt) + return result.scalars().one() + + + @classmethod + async def add_bulk(cls, session: AsyncSession, data: List[Dict[str, Any]]): + try: + result = await session.execute( + insert(cls.model).returning(cls.model), + data + ) + return result.scalars().all() + except (SQLAlchemyError, Exception) as e: + if isinstance(e, SQLAlchemyError): + msg = "Database Exc" + elif isinstance(e, Exception): + msg = "Unknown Exc" + msg += ": Cannot bulk insert data into table" + + log.error(msg, extra={"table": cls.model.__tablename__}, exc_info=True) + return None + + @classmethod + async def update_bulk(cls, session: AsyncSession, data: List[Dict[str, Any]]): + try: + stmt = update(cls.model) + await session.execute(update(cls.model), data) + except (SQLAlchemyError, Exception) as e: + if isinstance(e, SQLAlchemyError): + msg = "Database Exc" + elif isinstance(e, Exception): + msg = "Unknown Exc" + msg += ": Cannot bulk update data into table" + + log.error(msg, extra={"table": cls.model.__tablename__}, exc_info=True) + return None + + @classmethod + async def count(cls, session: AsyncSession, *filter, **filter_by): + stmt = select(func.count()).select_from( + cls.model).filter(*filter).filter_by(**filter_by) + result = await session.execute(stmt) + return result.scalar() \ No newline at end of file diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100755 index 0000000..fbd4f39 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker +from sqlalchemy.engine import create_engine +from sqlalchemy import MetaData, NullPool, func + +from app.config import settings +from app.constants import DB_NAMING_CONVENTION + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=DB_NAMING_CONVENTION) + + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) + + +if settings.MODE == "TEST": + DATABASE_URL = settings.TEST_DATABASE_URL + DATABASE_PARAMS = {"poolclass": NullPool} +else: + DATABASE_URL = settings.DATABASE_URL + DATABASE_PARAMS = {} + +async_engine = create_async_engine(DATABASE_URL, **DATABASE_PARAMS) +async_session_maker = async_sessionmaker(async_engine, expire_on_commit=False) \ No newline at end of file diff --git a/backend/app/exceptions.py b/backend/app/exceptions.py new file mode 100644 index 0000000..aa4914b --- /dev/null +++ b/backend/app/exceptions.py @@ -0,0 +1,16 @@ +from fastapi import HTTPException, status + + +class InvalidTokenException(HTTPException): + def __init__(self): + super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + +class TokenExpiredException(HTTPException): + def __init__(self): + super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired") + + +class InvalidCredentialsException(HTTPException): + def __init__(self): + super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password") diff --git a/backend/app/log_config.py b/backend/app/log_config.py new file mode 100755 index 0000000..2be9c98 --- /dev/null +++ b/backend/app/log_config.py @@ -0,0 +1,38 @@ +from logging.config import dictConfig + +from app.config import settings + +LOGGING_CONFIG = { + "version": 1, + "disable_existing_loggers": False, + + "formatters": { + "colored": { + "()": "colorlog.ColoredFormatter", + "format": "[%(asctime)s] %(log_color)s%(levelname)s%(reset)s:" + " (%(module)s) %(message)s", + "log_colors": { + "DEBUG": "cyan", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "purple", + }, + }, + }, + + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "colored", + }, + }, + + "root": { + "level": settings.LOG_LEVEL, + "handlers": ["console"], + }, +} + +def set_logging(): + dictConfig(LOGGING_CONFIG) \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py old mode 100644 new mode 100755 index b106944..185dcad --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,14 +1,85 @@ +from contextlib import asynccontextmanager import uvicorn +import logging -from fastapi import FastAPI +from fastapi import FastAPI, APIRouter, Request, Response +from fastapi.middleware.cors import CORSMiddleware -app = FastAPI() +from app.utils.redis import close_redis, init_redis +from app.users.router import user_router, auth_router +from app.log_config import set_logging +from app.config import settings + +set_logging() +log = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_redis() + log.info("Redis connected") + yield + await close_redis() + log.info("Redis disconnected") + + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(user_router) +api_router.include_router(auth_router) +app = FastAPI( + title=settings.COMPANY_NAME, + description="## Backend messenger aether", + lifespan=lifespan +) +app.include_router(api_router) + + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=settings.CORS_METHODS, + allow_headers=settings.CORS_HEADERS, +) + + +@app.middleware("http") +async def log_requests(request: Request, call_next): + response: Response = await call_next(request) + log.info( + "method=%s path=%s status=%s", + request.method, + request.url.path, + response.status_code, + extra={ + "method": request.method, + "path": request.url.path, + "status": response.status_code + } + ) + return response @app.get("/health") async def test_health(): - return {"status": "ok"} + return {"status": True} if __name__ == "__main__": - uvicorn.run("app.main:app", host="0.0.0.0", port=8080, reload=True, workers=3) \ No newline at end of file + if settings.MODE == "PROD": + UVICORN_PARAMS = dict( + host=settings.HOST, + port=settings.PORT, + reload=False, + workers=settings.WORKERS, + access_log=False + ) + else: + UVICORN_PARAMS = dict( + host=settings.HOST, + port=settings.PORT, + reload=True, + access_log=False + ) + log.info("app is starting") + uvicorn.run("app.main:app", **UVICORN_PARAMS) \ No newline at end of file diff --git a/backend/app/migration/README b/backend/app/migration/README new file mode 100755 index 0000000..e0d0858 --- /dev/null +++ b/backend/app/migration/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/backend/app/migration/env.py b/backend/app/migration/env.py new file mode 100755 index 0000000..7e4262f --- /dev/null +++ b/backend/app/migration/env.py @@ -0,0 +1,93 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +from app.users.models import UserModel +from app.database import Base +from app.config import settings + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) +# 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. + + +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() diff --git a/backend/app/migration/script.py.mako b/backend/app/migration/script.py.mako new file mode 100755 index 0000000..1101630 --- /dev/null +++ b/backend/app/migration/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"} diff --git a/backend/app/migration/versions/4d00c9b0516e_add_user_table.py b/backend/app/migration/versions/4d00c9b0516e_add_user_table.py new file mode 100644 index 0000000..b7c5d3a --- /dev/null +++ b/backend/app/migration/versions/4d00c9b0516e_add_user_table.py @@ -0,0 +1,48 @@ +"""ADD: user table + +Revision ID: 4d00c9b0516e +Revises: 52b7263bba19 +Create Date: 2026-01-04 14:04:33.143440 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4d00c9b0516e' +down_revision: Union[str, Sequence[str], None] = '52b7263bba19' +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.alter_column('user', 'birth_day', + existing_type=sa.DATE(), + nullable=True) + op.alter_column('user', 'description', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('user', 'avatar_url', + existing_type=sa.VARCHAR(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('user', 'avatar_url', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('user', 'description', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('user', 'birth_day', + existing_type=sa.DATE(), + nullable=False) + # ### end Alembic commands ### diff --git a/backend/app/migration/versions/52b7263bba19_add_user_table.py b/backend/app/migration/versions/52b7263bba19_add_user_table.py new file mode 100644 index 0000000..2ce227e --- /dev/null +++ b/backend/app/migration/versions/52b7263bba19_add_user_table.py @@ -0,0 +1,47 @@ +"""ADD: user table + +Revision ID: 52b7263bba19 +Revises: 9b013a15d8fb +Create Date: 2026-01-04 13:48:55.058538 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '52b7263bba19' +down_revision: Union[str, Sequence[str], None] = '9b013a15d8fb' +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.drop_index(op.f('refresh_session_id_idx'), table_name='refresh_session') + op.drop_index(op.f('refresh_session_refresh_token_idx'), table_name='refresh_session') + op.drop_table('refresh_session') + op.add_column('user', sa.Column('hashed_password', sa.String(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'hashed_password') + op.create_table('refresh_session', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('refresh_token', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('expires_in', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('user_ud', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['user_ud'], ['user.id'], name=op.f('refresh_session_user_ud_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('refresh_session_pkey')) + ) + op.create_index(op.f('refresh_session_refresh_token_idx'), 'refresh_session', ['refresh_token'], unique=False) + op.create_index(op.f('refresh_session_id_idx'), 'refresh_session', ['id'], unique=False) + # ### end Alembic commands ### diff --git a/backend/app/migration/versions/9b013a15d8fb_initial_revision.py b/backend/app/migration/versions/9b013a15d8fb_initial_revision.py new file mode 100755 index 0000000..ed2c4b8 --- /dev/null +++ b/backend/app/migration/versions/9b013a15d8fb_initial_revision.py @@ -0,0 +1,67 @@ +"""Initial revision + +Revision ID: 9b013a15d8fb +Revises: +Create Date: 2025-12-21 17:27:03.170318 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9b013a15d8fb' +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('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('birth_day', sa.DATE(), nullable=False), + sa.Column('description', sa.String(), nullable=False), + sa.Column('avatar_url', sa.String(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_verified', sa.Boolean(), nullable=False), + sa.Column('is_superuser', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('user_pkey')) + ) + op.create_index(op.f('user_email_idx'), 'user', ['email'], unique=True) + op.create_index(op.f('user_id_idx'), 'user', ['id'], unique=False) + op.create_index(op.f('user_username_idx'), 'user', ['username'], unique=True) + op.create_table('refresh_session', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('refresh_token', sa.UUID(), nullable=False), + sa.Column('expires_in', sa.Integer(), nullable=False), + sa.Column('user_ud', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_ud'], ['user.id'], name=op.f('refresh_session_user_ud_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('refresh_session_pkey')) + ) + op.create_index(op.f('refresh_session_id_idx'), 'refresh_session', ['id'], unique=False) + op.create_index(op.f('refresh_session_refresh_token_idx'), 'refresh_session', ['refresh_token'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('refresh_session_refresh_token_idx'), table_name='refresh_session') + op.drop_index(op.f('refresh_session_id_idx'), table_name='refresh_session') + op.drop_table('refresh_session') + op.drop_index(op.f('user_username_idx'), table_name='user') + op.drop_index(op.f('user_id_idx'), table_name='user') + op.drop_index(op.f('user_email_idx'), table_name='user') + op.drop_table('user') + # ### end Alembic commands ### diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..277fb70 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,33 @@ +import logging + +from app.utils.email_client import EmailClient +from app.config import settings + +log = logging.getLogger(__name__) + + +class EmailService: + @classmethod + def send_verify_email(cls, email: str, username: str, url: str): + log.debug("Sending email to %s", email, extra={"email": email}) + try: + subject = "Подтверждение эл. почты" + html = EmailClient.render( + template_path="confirm_email.html", + username=username, + url=url, + expire_minutes=60, + company_name=settings.COMPANY_NAME + ) + body = EmailClient.render( + template_path="confirm_email.txt", + username=username, + url=url, + company_name=settings.COMPANY_NAME + ) + + EmailClient.send_email(to=email, subject=subject, html=html, body=body) + log.info("Verify email sent to %s", email, extra={"email": email}) + except Exception as e: + log.error("Failed to send email to %s", email, extra={"email": email}) + raise e \ No newline at end of file diff --git a/backend/app/tasks/__init__.py b/backend/app/tasks/__init__.py new file mode 100644 index 0000000..85458a9 --- /dev/null +++ b/backend/app/tasks/__init__.py @@ -0,0 +1 @@ +from .email_tasks import * \ No newline at end of file diff --git a/backend/app/tasks/email_tasks.py b/backend/app/tasks/email_tasks.py new file mode 100644 index 0000000..ae6b703 --- /dev/null +++ b/backend/app/tasks/email_tasks.py @@ -0,0 +1,13 @@ +import logging + +from app.utils.celery_app import celery_app +from app.services.email_service import EmailService + +log = logging.getLogger(__name__) + +class EmailTasks: + + @staticmethod + @celery_app.task + def send_verify_email_task(email: str, username: str, url: str): + EmailService.send_verify_email(email, username, url) \ No newline at end of file diff --git a/backend/app/templates/confirm_email.html b/backend/app/templates/confirm_email.html new file mode 100644 index 0000000..f269591 --- /dev/null +++ b/backend/app/templates/confirm_email.html @@ -0,0 +1,163 @@ + + + + + + Подтверждение email — {{company_name}} + + + +
+
+
{{company_name}}
+
+ + +
+ + \ No newline at end of file diff --git a/backend/app/templates/confirm_email.txt b/backend/app/templates/confirm_email.txt new file mode 100644 index 0000000..4c8463a --- /dev/null +++ b/backend/app/templates/confirm_email.txt @@ -0,0 +1,12 @@ +Здравствуйте, {{username}}! + +Спасибо за регистрацию! + +Для подтверждения вашего email адреса перейдите по ссылке: +{{url}} + +Если вы не создавали аккаунт на нашем сайте, просто проигнорируйте это письмо. +Внимание: Эта ссылка действительна в течение {{expire_minutes}} минут + +С уважением, +Команда {{company_name}} diff --git a/backend/app/templates/reset_password.html b/backend/app/templates/reset_password.html new file mode 100644 index 0000000..7591b3d --- /dev/null +++ b/backend/app/templates/reset_password.html @@ -0,0 +1,180 @@ + + + + + + Восстановление пароля — {{company_name}} + + + +
+
+
{{company_name}}
+
+ + +
+ + \ No newline at end of file diff --git a/backend/app/templates/reset_password.txt b/backend/app/templates/reset_password.txt new file mode 100644 index 0000000..9a2baea --- /dev/null +++ b/backend/app/templates/reset_password.txt @@ -0,0 +1,13 @@ +Здравствуйте, {{username}}! + +Мы получили запрос на сброс пароля для вашей учетной записи. + +Для создания нового пароля перейдите по ссылке: +{{url}} + +ВНИМАНИЕ: Эта ссылка действительна в течение {{expire_minutes}} минут. + +Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо. Ваш пароль останется без изменений. + +С уважением, +Команда {{company_name}} diff --git a/backend/app/users/dao.py b/backend/app/users/dao.py new file mode 100644 index 0000000..e6fcf94 --- /dev/null +++ b/backend/app/users/dao.py @@ -0,0 +1,7 @@ +from app.dao import BaseDAO +from app.users.models import UserModel +from app.users.schemas import UserCreateDB, UserUpdateDB + + +class UserDAO(BaseDAO[UserModel, UserCreateDB, UserUpdateDB]): + model = UserModel \ No newline at end of file diff --git a/backend/app/users/dependencies.py b/backend/app/users/dependencies.py new file mode 100644 index 0000000..eefa70c --- /dev/null +++ b/backend/app/users/dependencies.py @@ -0,0 +1,56 @@ +import logging +from typing import Optional +import uuid + +from fastapi import Depends, HTTPException, status +from jose import jwt, JWTError + +from app.utils.OAuth2WithCookie import OAuth2PasswordBearerWithCookie +from app.config import settings +from app.users.models import UserModel +from app.users.service import UserService +from app.exceptions import InvalidTokenException + +log = logging.getLogger(__name__) +oauth2_scheme = OAuth2PasswordBearerWithCookie(tokenUrl="/api/v1/auth/login") + +async def get_current_user(token: str = Depends(oauth2_scheme)) -> Optional[UserModel]: + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=settings.ALGORITHM) + user_id = int(payload.get("sub")) + log.debug("Successfully get current_user id", extra={"user_id": user_id}) + + if user_id is None: + log.warning("User id is None") + raise InvalidTokenException + except (Exception, JWTError) as ex: + if isinstance(ex, InvalidTokenException): + raise ex + + if isinstance(ex, JWTError): + log.error("JWT error") + raise ex + + log.error("Unknown exception") + raise ex + + current_user = await UserService.get_user(user_id) + + if not current_user.is_active: + log.debug("User is not active", extra={"user_id": current_user.id}) + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not active") + + return current_user + + +async def get_current_superuser(current_user: UserModel = Depends(get_current_user)) -> Optional[UserModel]: + if not current_user.is_superuser: + log.debug("User not enough privileges", extra={"user_id": current_user.id}) + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Not enough privileges") + + return current_user + +async def get_current_verified_user(current_user: UserModel = Depends(get_current_user)): + if not current_user.is_verified: + log.debug("User has not confirmed the email.", extra={"user_id": current_user.id}) + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="verify email") \ No newline at end of file diff --git a/backend/app/users/models.py b/backend/app/users/models.py new file mode 100755 index 0000000..f0e0b69 --- /dev/null +++ b/backend/app/users/models.py @@ -0,0 +1,30 @@ +from datetime import date +import uuid + +from sqlalchemy.orm import mapped_column, Mapped +from sqlalchemy import DATE, UUID, ForeignKey + +from app.database import Base + +class UserModel(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + display_name: Mapped[str] = mapped_column() + username: Mapped[str] = mapped_column(index=True, unique=True) + email: Mapped[str] = mapped_column(index=True, unique=True) + birth_day: Mapped[date] = mapped_column(DATE, nullable=True) + description: Mapped[str] = mapped_column(nullable=True) + avatar_url: Mapped[str] = mapped_column(nullable=True) + is_active: Mapped[bool] = mapped_column(default=True) + is_verified: Mapped[bool] = mapped_column(default=False) + is_superuser: Mapped[bool] = mapped_column(default=False) + hashed_password: Mapped[str] = mapped_column() + +# class RefreshSessionModel(Base): +# __tablename__ = "refresh_session" +# +# id: Mapped[int] = mapped_column(primary_key=True, index=True) +# refresh_token: Mapped[uuid.UUID] = mapped_column(UUID, index=True) +# expires_in: Mapped[int] = mapped_column() +# user_ud: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE")) \ No newline at end of file diff --git a/backend/app/users/router.py b/backend/app/users/router.py new file mode 100755 index 0000000..89453f9 --- /dev/null +++ b/backend/app/users/router.py @@ -0,0 +1,80 @@ +import logging +import uuid + +from fastapi import APIRouter, status, Response, Depends, Request +from fastapi.security import OAuth2PasswordRequestForm + +from app.users.schemas import UserCreate, User, Token +from app.users.service import AuthService, UserService +from app.users.models import UserModel +from app.exceptions import InvalidCredentialsException +from app.users.dependencies import get_current_user, get_current_verified_user +from app.config import settings + +user_router = APIRouter(prefix="/users", tags=["User"]) +auth_router = APIRouter(prefix="/auth", tags=["Auth"]) + +log = logging.getLogger(__name__) + + +@auth_router.post("/register", status_code=status.HTTP_201_CREATED) +async def register(user: UserCreate) -> User: + return await UserService.register_new_user(user) + +@auth_router.get("/verify/{token}") +async def verify_email(token: uuid.UUID): + await UserService.verify_email(token) + return {"status": True} + +@auth_router.post("/send/verify_email") +async def resend_verify_email(user: UserModel = Depends(get_current_user)): + await UserService.send_verify_email(user) + return {"status": True, "message": "Successfully send email letter"} + +@auth_router.post("/login") +async def login(response: Response, credentials: OAuth2PasswordRequestForm = Depends()) -> Token: + user = await AuthService.authenticate_user(credentials.username, credentials.password) + if not user: + log.warning("Failed login attempt", extra={"email or username": credentials.username}) + raise InvalidCredentialsException + token = await AuthService.create_token(user.id) + response.set_cookie( + 'access_token', + token.access_token, + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + httponly=True + ) + response.set_cookie( + 'refresh_token', + str(token.refresh_token), + max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60, + httponly=True + ) + return token + +@auth_router.post("/refresh") +async def refresh_token(request: Request, response: Response) -> Token: + new_token = await AuthService.refresh_token(uuid.UUID(request.cookies.get("refresh_token"))) + + response.set_cookie( + 'access_token', + new_token.access_token, + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + httponly=True + ) + response.set_cookie( + 'refresh_token', + str(new_token.refresh_token), + max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60, + httponly=True + ) + log.debug("Token refreshed via endpoint") + return new_token + +@auth_router.post("/logout") +async def logout(request: Request, response: Response, user: UserModel = Depends(get_current_user)): + response.delete_cookie("access_token") + response.delete_cookie("refresh_token") + + await AuthService.logout(uuid.UUID(request.cookies.get("refresh_token"))) + return {"status": True, "message": "Logged out successfully"} \ No newline at end of file diff --git a/backend/app/users/schemas.py b/backend/app/users/schemas.py new file mode 100644 index 0000000..028b215 --- /dev/null +++ b/backend/app/users/schemas.py @@ -0,0 +1,64 @@ +from typing import Optional +from datetime import date +import uuid + +from pydantic import BaseModel, EmailStr + + +class UserBase(BaseModel): + display_name: Optional[str] = None + username: Optional[str] = None + # email: Optional[str] = None + birth_day: Optional[date] = None + # description: str + # avatar_url: str + + +class UserCreate(UserBase): + display_name: str + username: str + email: EmailStr + password: str + + class Config: + from_attributes = True + + +class UserUpdate(UserBase): + description: Optional[str] = None + birth_day: Optional[date] = None + + +class User(UserBase): + id: int + display_name: str + username: str + email: EmailStr + birth_day: Optional[date] = None + description: Optional[str] = None + avatar_url: Optional[str] = None + is_active: bool + is_verified: bool + is_superuser: bool + +class UserCreateDB(UserBase): + email: Optional[str] = None + hashed_password: Optional[str] = None + is_active: Optional[bool] = None + is_verified: Optional[bool] = None + is_superuser: Optional[bool] = None + +class UserUpdateDB(UserBase): + email: Optional[str] = None + hashed_password: str + description: Optional[str] = None + birth_day: Optional[date] = None + is_active: Optional[bool] = None + is_verified: Optional[bool] = None + is_superuser: Optional[bool] = None + +class Token(BaseModel): + access_token: str + refresh_token: uuid.UUID + token_type: str + diff --git a/backend/app/users/service.py b/backend/app/users/service.py new file mode 100644 index 0000000..7d5f913 --- /dev/null +++ b/backend/app/users/service.py @@ -0,0 +1,193 @@ +import logging +import uuid +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import HTTPException, status +from jose import jwt +from sqlalchemy import or_ + +from app.utils.hash_password import hash_password, verify_password +from app.utils.redis import get_redis +from app.exceptions import InvalidTokenException, TokenExpiredException +from app.users.models import UserModel +from app.users.dao import UserDAO +from app.database import async_session_maker +from app.users.schemas import Token, UserCreate, UserCreateDB, User +from app.tasks.email_tasks import EmailTasks +from app.config import settings + +log = logging.getLogger(__name__) + + +class AuthService: + @classmethod + async def create_token(cls, user_id: int) -> Token: + redis_client = await get_redis() + + access_token = cls._create_access_token(user_id) + refresh_token_expires = timedelta( + days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + refresh_token = cls._create_refresh_token() + + await redis_client.setex(f"refresh:{refresh_token}", int(refresh_token_expires.total_seconds()), user_id) + + log.info("Token created has user", extra={"user_id": user_id}) + return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer") + + @classmethod + async def logout(cls, token: uuid.UUID) -> None: + redis_client = await get_redis() + user_id = await redis_client.getdel(f"refresh:{token}") + log.info("User logged out", extra={"user_id": user_id}) + + @classmethod + async def refresh_token(cls, token: uuid.UUID) -> Token: + redis_client = await get_redis() + async with async_session_maker() as session: + refresh_session = await redis_client.getdel(f"refresh:{token}") + + if refresh_session is None: + log.warning("Refresh token not found") + raise InvalidTokenException + + user = await UserDAO.find_one_or_none(session, id=int(refresh_session)) + if user is None: + log.error("User not found during token refresh", extra={"user_id": str(refresh_session.user_id)}) + raise InvalidTokenException + + access_token = cls._create_access_token(user.id) + refresh_token_expires = timedelta( + days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + refresh_token = cls._create_refresh_token() + + await redis_client.setex( + f"refresh:{refresh_token}", + int(refresh_token_expires.total_seconds()), + user.id + ) + + await session.commit() + log.info("Token refreshed for user", extra={"user_id": str(user.id)}) + return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer") + + @classmethod + async def authenticate_user(cls, email_or_username: str, password: str) -> Optional[UserModel]: + async with async_session_maker() as session: + db_user = await UserDAO.find_one_or_none( + session, + or_( + UserModel.email==email_or_username, + UserModel.username==email_or_username + ) + ) + if db_user and verify_password(password, db_user.hashed_password): + log.info("User authenticated successfully", extra={"username": db_user.username}) + return db_user + log.warning("Authentication failed", extra={"email": email_or_username}) + return None + + # @classmethod + # async def abort_all_sessions(cls, user_id: uuid.UUID): + # async with async_session_maker() as session: + # await RefreshSessionDAO.delete(session, RefreshSessionModel.user_id == user_id) + # await session.commit() + + @classmethod + def _create_access_token(cls, user_id: int) -> str: + to_encode = { + "sub": str(user_id), + "exp": datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + } + encoded_jwt = jwt.encode( + to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return f'Bearer {encoded_jwt}' + + @classmethod + def _create_refresh_token(cls) -> str: + return uuid.uuid4() + + +class UserService: + @classmethod + async def get_user(cls, user_id: int) -> User: + async with async_session_maker() as session: + user_exist = await UserDAO.find_one_or_none(session, id=user_id) + if user_exist is None: + log.warning("User not found", extra={"user_id": user_id}) + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found") + log.debug("User fetched", extra={"user_id": user_id}) + + return user_exist + + + @classmethod + async def register_new_user(cls, user: UserCreate) -> User: + redis_client = await get_redis() + + async with async_session_maker() as session: + user_exist = await UserDAO.find_one_or_none(session, or_( + UserModel.email==user.email, + UserModel.username==user.username + )) + + if user_exist: + log.warning("User already registered", extra={"email": user.email}) + raise HTTPException(status_code=400, detail="User already exists") + print(user.email) + + user_db = await UserDAO.add( + session, + UserCreateDB( + **user.model_dump(), + hashed_password=hash_password(user.password), + is_active=True, + is_verified=False, + is_superuser=False + ) + ) + await session.commit() + + await cls.send_verify_email(user_db) + return user_db + + + @classmethod + async def send_verify_email(cls, user: UserModel): + redis_client = await get_redis() + + token = cls._create_email_verification_token() + url = f"{settings.URL}/api/v1/auth/verify/{token}" + + await redis_client.setex(f"email:{token}", timedelta(minutes=60), user.id) + EmailTasks.send_verify_email_task.delay(email=user.email, username=user.username, url=url) + + + @classmethod + def _create_email_verification_token(cls) -> uuid.UUID: + return uuid.uuid4() + + + @classmethod + async def verify_email(cls, token: uuid.UUID): + redis_client = await get_redis() + async with async_session_maker() as session: + user_id = await redis_client.getdel(f"email:{token}") + + if user_id is None: + raise TokenExpiredException + + user_exist = await UserDAO.find_one_or_none(session, id=int(user_id)) + if user_exist is None: + raise InvalidTokenException + if user_exist.is_verified: + raise HTTPException(status_code=400, detail="Email already verified") + + await UserDAO.update( + session, + UserModel.id==int(user_id), + obj_in={"is_verified": True} + ) + + await session.commit() diff --git a/backend/app/utils/OAuth2WithCookie.py b/backend/app/utils/OAuth2WithCookie.py new file mode 100644 index 0000000..da858c3 --- /dev/null +++ b/backend/app/utils/OAuth2WithCookie.py @@ -0,0 +1,36 @@ +from typing import Dict, Optional + +from fastapi import HTTPException, Request, status +from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel +from fastapi.security import OAuth2 +from fastapi.security.utils import get_authorization_scheme_param + + +class OAuth2PasswordBearerWithCookie(OAuth2): + def __init__( + self, + tokenUrl: str, + scheme_name: Optional[str] = None, + scopes: Optional[Dict[str, str]] = None, + auto_error: bool = True, + ): + if not scopes: + scopes = {} + flows = OAuthFlowsModel( + password={"tokenUrl": tokenUrl, "scopes": scopes}) + super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error) + + async def __call__(self, request: Request) -> Optional[str]: + authorization: str = request.cookies.get("access_token") + + scheme, param = get_authorization_scheme_param(authorization) + if not authorization or scheme.lower() != "bearer": + if self.auto_error: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + return None + return param \ No newline at end of file diff --git a/backend/app/utils/cache.py b/backend/app/utils/cache.py new file mode 100755 index 0000000..b3651a4 --- /dev/null +++ b/backend/app/utils/cache.py @@ -0,0 +1,27 @@ +import json +from functools import wraps + +from fastapi import Request + +from app.utils.redis import get_redis + + +def cache(ttl: int = 10): + if ttl <= 0: + raise ValueError("TTL must be greater than zero.") + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + redis = await get_redis() + request: Request = kwargs.get("request") + response_cache = await redis.get(str(request.url)) + if response_cache is not None: + return json.loads(response_cache) + + response_cache = await func(*args, **kwargs) + await redis.setex(str(request.url), ttl, json.dumps(response_cache)) + return response_cache + + return wrapper + return decorator diff --git a/backend/app/utils/celery_app.py b/backend/app/utils/celery_app.py new file mode 100644 index 0000000..f3baeb7 --- /dev/null +++ b/backend/app/utils/celery_app.py @@ -0,0 +1,11 @@ +from celery import Celery + +from app.config import settings + +celery_app = Celery( + "app.utils.celery_app", + broker=settings.RABBITMQ_URL, + backend="rpc://" +) + +celery_app.autodiscover_tasks(["app.tasks"]) \ No newline at end of file diff --git a/backend/app/utils/email_client.py b/backend/app/utils/email_client.py new file mode 100644 index 0000000..cdb4574 --- /dev/null +++ b/backend/app/utils/email_client.py @@ -0,0 +1,46 @@ +import smtplib +import logging +import os + +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +from jinja2 import Environment, FileSystemLoader + +from app.config import settings + +log = logging.getLogger(__name__) + +class EmailClient: + env = Environment( + loader=FileSystemLoader(os.path.join(os.path.dirname(__file__), "..", "templates")) + ) + + @classmethod + def render(cls, template_path, **kwargs): + log.debug(f"Rendering {template_path}", extra={"kwargs": kwargs, "template_path": template_path}) + template = cls.env.get_template(template_path) + return template.render(**kwargs) + + + @classmethod + def send_email(cls, to: str, subject: str, html: str, body: str): + log.info("Sending email", extra={"subject": subject, "to": to}) + try: + msg = MIMEMultipart() + msg["Subject"] = subject + msg["From"] = settings.SMTP_EMAIL + msg["To"] = to + + msg.attach(MIMEText(html, "html", "utf-8")) + msg.attach(MIMEText(body, "plain", "utf-8")) + + with smtplib.SMTP(settings.SMTP_SERVER, settings.SMTP_PORT) as smtp: + if not settings.MODE == "DEV": + smtp.starttls() + smtp.login(settings.SMTP_EMAIL, settings.SMTP_PASS) + smtp.send_message(msg) + log.info("Email sent successfully", extra={"to": to, "subject": subject}) + except Exception as e: + log.error(f"Failed to send email: {str(e)}", extra={"to": to, "subject": subject}) + raise e \ No newline at end of file diff --git a/backend/app/utils/hash_password.py b/backend/app/utils/hash_password.py new file mode 100644 index 0000000..74b7a1a --- /dev/null +++ b/backend/app/utils/hash_password.py @@ -0,0 +1,10 @@ +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password): + return pwd_context.hash(password) + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) \ No newline at end of file diff --git a/backend/app/utils/redis.py b/backend/app/utils/redis.py new file mode 100755 index 0000000..f48a028 --- /dev/null +++ b/backend/app/utils/redis.py @@ -0,0 +1,22 @@ +from redis.asyncio import Redis, from_url + +from app.config import settings + +redis_client: Redis = None + +async def init_redis() -> None: + global redis_client + redis_client = await from_url( + settings.REDIS_URL, + encoding="utf-8", + decode_responses=True + ) + + +async def close_redis() -> None: + if redis_client: + await redis_client.close() + + +async def get_redis() -> Redis: + return redis_client \ No newline at end of file diff --git a/backend/docker-compose.dev.yml b/backend/docker-compose.dev.yml new file mode 100755 index 0000000..0e757b2 --- /dev/null +++ b/backend/docker-compose.dev.yml @@ -0,0 +1,73 @@ +services: + postgres-db: + image: postgres:latest + volumes: + - pgdata:/var/lib/postgresql + environment: + POSTGRES_DB: "${DB_NAME}" + POSTGRES_USER: "${DB_USER}" + POSTGRES_PASSWORD: "${DB_PASS}" + networks: + - aether-dev + ports: + - "${DB_PORT}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"] + interval: 5s + retries: 5 + restart: always + + redis: + image: redis:latest + volumes: + - redis_data:/data + networks: + - aether-dev + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + retries: 5 + restart: always + + redis-insight: + image: redis/redisinsight:latest + container_name: redis_insight + ports: + - "5540:5540" + networks: + - aether-dev + depends_on: + - redis + + maildev: + image: maildev/maildev + ports: + - "1080:1080" + - "${SMTP_PORT}:1025" + restart: unless-stopped + + rabbitmq: + image: rabbitmq:3.8-management + hostname: rabbitmq + container_name: rabbitmq + ports: + - "${RMQ_PORT}:5672" + - "15672:15672" + networks: + - aether-dev + environment: + RABBITMQ_DEFAULT_USER: "${RMQ_USER}" + RABBITMQ_DEFAULT_PASS: "${RMQ_PASS}" + volumes: + - rabbitmq-data:/var/lib/rabbitmq + restart: unless-stopped + +volumes: + pgdata: + redis_data: + rabbitmq-data: + +networks: + aether-dev: \ No newline at end of file diff --git a/backend/poetry.lock b/backend/poetry.lock old mode 100644 new mode 100755 index 2e4bd3a..e44e071 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,5 +1,40 @@ # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +[[package]] +name = "alembic" +version = "1.17.2" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6"}, + {file = "alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.4.0" +typing-extensions = ">=4.12" + +[package.extras] +tz = ["tzdata"] + +[[package]] +name = "amqp" +version = "5.3.1" +description = "Low-level AMQP client for Python (fork of amqplib)." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2"}, + {file = "amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432"}, +] + +[package.dependencies] +vine = ">=5.0.0,<6.0.0" + [[package]] name = "annotated-doc" version = "0.0.4" @@ -42,6 +77,181 @@ idna = ">=2.8" [package.extras] trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] +[[package]] +name = "asyncpg" +version = "0.31.0" +description = "An asyncio PostgreSQL driver" +optional = false +python-versions = ">=3.9.0" +groups = ["main"] +files = [ + {file = "asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61"}, + {file = "asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be"}, + {file = "asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8"}, + {file = "asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1"}, + {file = "asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3"}, + {file = "asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8"}, + {file = "asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095"}, + {file = "asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540"}, + {file = "asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d"}, + {file = "asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab"}, + {file = "asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c"}, + {file = "asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109"}, + {file = "asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da"}, + {file = "asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9"}, + {file = "asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24"}, + {file = "asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047"}, + {file = "asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad"}, + {file = "asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d"}, + {file = "asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a"}, + {file = "asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671"}, + {file = "asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec"}, + {file = "asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20"}, + {file = "asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8"}, + {file = "asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186"}, + {file = "asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b"}, + {file = "asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e"}, + {file = "asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403"}, + {file = "asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4"}, + {file = "asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2"}, + {file = "asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602"}, + {file = "asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696"}, + {file = "asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab"}, + {file = "asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44"}, + {file = "asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5"}, + {file = "asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2"}, + {file = "asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2"}, + {file = "asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218"}, + {file = "asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d"}, + {file = "asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b"}, + {file = "asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be"}, + {file = "asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2"}, + {file = "asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31"}, + {file = "asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7"}, + {file = "asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e"}, + {file = "asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c"}, + {file = "asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a"}, + {file = "asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d"}, + {file = "asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3"}, + {file = "asyncpg-0.31.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ebb3cde58321a1f89ce41812be3f2a98dddedc1e76d0838aba1d724f1e4e1a95"}, + {file = "asyncpg-0.31.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6974f36eb9a224d8fb428bcf66bd411aa12cf57c2967463178149e73d4de366"}, + {file = "asyncpg-0.31.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2b685f400ceae428f79f78b58110470d7b4466929a7f78d455964b17ad1008"}, + {file = "asyncpg-0.31.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb223567dea5f47c45d347f2bde5486be8d9f40339f27217adb3fb1c3be51298"}, + {file = "asyncpg-0.31.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:22be6e02381bab3101cd502d9297ac71e2f966c86e20e78caead9934c98a8af6"}, + {file = "asyncpg-0.31.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:37a58919cfef2448a920df00d1b2f821762d17194d0dbf355d6dde8d952c04f9"}, + {file = "asyncpg-0.31.0-cp39-cp39-win32.whl", hash = "sha256:c1a9c5b71d2371a2290bc93336cd05ba4ec781683cab292adbddc084f89443c6"}, + {file = "asyncpg-0.31.0-cp39-cp39-win_amd64.whl", hash = "sha256:c1e1ab5bc65373d92dd749d7308c5b26fb2dc0fbe5d3bf68a32b676aa3bcd24a"}, + {file = "asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735"}, +] + +[package.extras] +gssauth = ["gssapi ; platform_system != \"Windows\"", "sspilib ; platform_system == \"Windows\""] + +[[package]] +name = "bcrypt" +version = "4.0.1" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f"}, + {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0"}, + {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410"}, + {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344"}, + {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a"}, + {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3"}, + {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2"}, + {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535"}, + {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e"}, + {file = "bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab"}, + {file = "bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9"}, + {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf4fa8b2ca74381bb5442c089350f09a3f17797829d958fad058d6e44d9eb83c"}, + {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:67a97e1c405b24f19d08890e7ae0c4f7ce1e56a712a016746c8b2d7732d65d4b"}, + {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b3b85202d95dd568efcb35b53936c5e3b3600c7cdcc6115ba461df3a8e89f38d"}, + {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbb03eec97496166b704ed663a53680ab57c5084b2fc98ef23291987b525cb7d"}, + {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:5ad4d32a28b80c5fa6671ccfb43676e8c1cc232887759d1cd7b6f56ea4355215"}, + {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b57adba8a1444faf784394de3436233728a1ecaeb6e07e8c22c8848f179b893c"}, + {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b2cea8a9ed3d55b4491887ceadb0106acf7c6387699fca771af56b1cdeeda"}, + {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:2b3ac11cf45161628f1f3733263e63194f22664bf4d0c0f3ab34099c02134665"}, + {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3100851841186c25f127731b9fa11909ab7b1df6fc4b9f8353f4f1fd952fbf71"}, + {file = "bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + +[[package]] +name = "billiard" +version = "4.2.4" +description = "Python multiprocessing fork with improvements and bugfixes" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5"}, + {file = "billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f"}, +] + +[[package]] +name = "celery" +version = "5.6.1" +description = "Distributed Task Queue." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "celery-5.6.1-py3-none-any.whl", hash = "sha256:ee87aa14d344c655fe83bfc44b2c93bbb7cba39ae11e58b88279523506159d44"}, + {file = "celery-5.6.1.tar.gz", hash = "sha256:bdc9e02b1480dd137f2df392358c3e94bb623d4f47ae1bc0a7dc5821c90089c7"}, +] + +[package.dependencies] +billiard = ">=4.2.1,<5.0" +click = ">=8.1.2,<9.0" +click-didyoumean = ">=0.3.0" +click-plugins = ">=1.1.1" +click-repl = ">=0.2.0" +kombu = ">=5.6.0" +python-dateutil = ">=2.8.2" +tzlocal = "*" +vine = ">=5.1.0,<6.0" + +[package.extras] +arangodb = ["pyArango (>=2.0.2)"] +auth = ["cryptography (==46.0.3)"] +azureblockblob = ["azure-identity (>=1.19.0)", "azure-storage-blob (>=12.15.0)"] +brotli = ["brotli (>=1.0.0) ; platform_python_implementation == \"CPython\"", "brotlipy (>=0.7.0) ; platform_python_implementation == \"PyPy\""] +cassandra = ["cassandra-driver (>=3.25.0,<4)"] +consul = ["python-consul2 (==0.1.5)"] +cosmosdbsql = ["pydocumentdb (==2.3.5)"] +couchbase = ["couchbase (>=3.0.0) ; platform_python_implementation != \"PyPy\" and (platform_system != \"Windows\" or python_version < \"3.10\")"] +couchdb = ["pycouchdb (==1.16.0)"] +django = ["Django (>=2.2.28)"] +dynamodb = ["boto3 (>=1.26.143)"] +elasticsearch = ["elastic-transport (<=9.1.0)", "elasticsearch (<=9.1.2)"] +eventlet = ["eventlet (>=0.32.0) ; python_version < \"3.10\""] +gcs = ["google-cloud-firestore (==2.22.0)", "google-cloud-storage (>=2.10.0)", "grpcio (==1.75.1)"] +gevent = ["gevent (>=1.5.0)"] +librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] +memcache = ["pylibmc (==1.6.3) ; platform_system != \"Windows\""] +mongodb = ["kombu[mongodb]"] +msgpack = ["kombu[msgpack]"] +pydantic = ["pydantic (>=2.12.0a1) ; python_version >= \"3.14\"", "pydantic (>=2.4) ; python_version < \"3.14\""] +pymemcache = ["python-memcached (>=1.61)"] +pyro = ["pyro4 (==4.82) ; python_version < \"3.11\""] +pytest = ["pytest-celery[all] (>=1.2.0,<1.3.0)"] +redis = ["kombu[redis]"] +s3 = ["boto3 (>=1.26.143)"] +slmq = ["softlayer_messaging (>=1.0.3)"] +solar = ["ephem (==4.2) ; platform_python_implementation != \"PyPy\""] +sqlalchemy = ["kombu[sqlalchemy]"] +sqs = ["boto3 (>=1.26.143)", "kombu[sqs] (>=5.5.0)", "pycurl (>=7.43.0.5,<7.45.4) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\" and python_version < \"3.9\"", "pycurl (>=7.45.4) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "urllib3 (>=1.26.16)"] +tblib = ["tblib (==3.2.2)"] +yaml = ["kombu[yaml]"] +zookeeper = ["kazoo (>=1.3.1)"] +zstd = ["zstandard (==0.23.0)"] + [[package]] name = "certifi" version = "2025.11.12" @@ -69,6 +279,58 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "click-didyoumean" +version = "0.3.1" +description = "Enables git-like *did-you-mean* feature in click" +optional = false +python-versions = ">=3.6.2" +groups = ["main"] +files = [ + {file = "click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c"}, + {file = "click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463"}, +] + +[package.dependencies] +click = ">=7" + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +description = "An extension module for click to enable registering CLI commands via setuptools entry-points." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6"}, + {file = "click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261"}, +] + +[package.dependencies] +click = ">=4.0" + +[package.extras] +dev = ["coveralls", "pytest (>=3.6)", "pytest-cov", "wheel"] + +[[package]] +name = "click-repl" +version = "0.3.0" +description = "REPL plugin for Click" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9"}, + {file = "click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812"}, +] + +[package.dependencies] +click = ">=7.0" +prompt-toolkit = ">=3.0.36" + +[package.extras] +testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"] + [[package]] name = "colorama" version = "0.4.6" @@ -82,6 +344,24 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "colorlog" +version = "6.10.1" +description = "Add colours to the output of Python's logging module." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c"}, + {file = "colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +development = ["black", "flake8", "mypy", "pytest", "types-colorama"] + [[package]] name = "dnspython" version = "2.8.0" @@ -103,6 +383,25 @@ idna = ["idna (>=3.10)"] trio = ["trio (>=0.30)"] wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] +[[package]] +name = "ecdsa" +version = "0.19.1" +description = "ECDSA cryptographic signature library (pure python)" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6" +groups = ["main"] +files = [ + {file = "ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3"}, + {file = "ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61"}, +] + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + [[package]] name = "email-validator" version = "2.3.0" @@ -549,6 +848,18 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -567,6 +878,62 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "kombu" +version = "5.6.2" +description = "Messaging library for Python." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93"}, + {file = "kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55"}, +] + +[package.dependencies] +amqp = ">=5.1.1,<6.0.0" +packaging = "*" +tzdata = ">=2025.2" +vine = "5.1.0" + +[package.extras] +azureservicebus = ["azure-servicebus (>=7.10.0)"] +azurestoragequeues = ["azure-identity (>=1.12.0)", "azure-storage-queue (>=12.6.0)"] +confluentkafka = ["confluent-kafka (>=2.2.0)"] +consul = ["python-consul2 (==0.1.5)"] +gcpubsub = ["google-cloud-monitoring (>=2.16.0)", "google-cloud-pubsub (>=2.18.4)", "grpcio (==1.75.1)", "protobuf (==6.32.1)"] +librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] +mongodb = ["pymongo (==4.15.3)"] +msgpack = ["msgpack (==1.1.2)"] +pyro = ["pyro4 (==4.82)"] +qpid = ["qpid-python (==1.36.0-1)", "qpid-tools (==1.36.0-1)"] +redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<6.5)"] +slmq = ["softlayer_messaging (>=1.0.3)"] +sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] +sqs = ["boto3 (>=1.26.143)", "pycurl (>=7.43.0.5) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "urllib3 (>=1.26.16)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=2.8.0)"] + +[[package]] +name = "mako" +version = "1.3.10" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"}, + {file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -702,6 +1069,82 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "passlib" +version = "1.7.4" +description = "comprehensive password hashing framework supporting over 30 schemes" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"}, + {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"}, +] + +[package.dependencies] +bcrypt = {version = ">=3.1.0", optional = true, markers = "extra == \"bcrypt\""} + +[package.extras] +argon2 = ["argon2-cffi (>=18.2.0)"] +bcrypt = ["bcrypt (>=3.1.0)"] +build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] +totp = ["cryptography"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"}, + {file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -859,6 +1302,30 @@ files = [ [package.dependencies] typing-extensions = ">=4.14.1" +[[package]] +name = "pydantic-settings" +version = "2.12.0" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809"}, + {file = "pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + [[package]] name = "pygments" version = "2.19.2" @@ -874,6 +1341,43 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pytest" +version = "9.0.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "python-dotenv" version = "1.2.1" @@ -889,6 +1393,29 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-jose" +version = "3.5.0" +description = "JOSE implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"}, + {file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"}, +] + +[package.dependencies] +ecdsa = "!=0.15" +pyasn1 = ">=0.5.0" +rsa = ">=4.0,<4.1.1 || >4.1.1,<4.4 || >4.4,<5.0" + +[package.extras] +cryptography = ["cryptography (>=3.4.0)"] +pycrypto = ["pycrypto (>=2.6.0,<2.7.0)"] +pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)"] +test = ["pytest", "pytest-cov"] + [[package]] name = "python-multipart" version = "0.0.20" @@ -984,6 +1511,24 @@ files = [ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +[[package]] +name = "redis" +version = "7.1.0" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b"}, + {file = "redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c"}, +] + +[package.extras] +circuit-breaker = ["pybreaker (>=1.4.0)"] +hiredis = ["hiredis (>=3.2.0)"] +jwt = ["pyjwt (>=2.9.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"] + [[package]] name = "rich" version = "14.2.0" @@ -1184,6 +1729,20 @@ files = [ {file = "rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671"}, ] +[[package]] +name = "rsa" +version = "4.2" +description = "Pure-Python RSA implementation" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "rsa-4.2.tar.gz", hash = "sha256:aaefa4b84752e3e99bd8333a2e1e3e7a7da64614042bd66f775573424370108a"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "sentry-sdk" version = "2.47.0" @@ -1259,6 +1818,18 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sqlalchemy" version = "2.0.45" @@ -1413,6 +1984,36 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "tzdata" +version = "2025.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +files = [ + {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, + {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, + {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] + [[package]] name = "urllib3" version = "2.6.2" @@ -1522,6 +2123,18 @@ dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx_rtd_theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=25.3.0,<25.4.0)", "pycodestyle (>=2.11.0,<2.12.0)"] +[[package]] +name = "vine" +version = "5.1.0" +description = "Python promises." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc"}, + {file = "vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0"}, +] + [[package]] name = "watchfiles" version = "1.1.1" @@ -1644,6 +2257,18 @@ files = [ [package.dependencies] anyio = ">=3.0.0" +[[package]] +name = "wcwidth" +version = "0.2.14" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1"}, + {file = "wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605"}, +] + [[package]] name = "websockets" version = "15.0.1" @@ -1726,4 +2351,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.13" -content-hash = "ea1d39e35c94495007335b8d90f5c19b26c0b8ee64fba8f68097046556ea1e4f" +content-hash = "d0a6bbe1a8d084b86ceef06004ff423ec7a13273f942cec7c584290224c32520" diff --git a/backend/pyproject.toml b/backend/pyproject.toml old mode 100644 new mode 100755 index 4672ac2..dfcd6c6 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,7 +9,17 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "fastapi[standard] (>=0.124.4,<0.125.0)", - "sqlalchemy (>=2.0.45,<3.0.0)" + "sqlalchemy (>=2.0.45,<3.0.0)", + "pydantic-settings (>=2.12.0,<3.0.0)", + "alembic (>=1.17.2,<2.0.0)", + "colorlog (>=6.10.1,<7.0.0)", + "asyncpg (>=0.31.0,<0.32.0)", + "redis (>=7.1.0,<8.0.0)", + "passlib[bcrypt] (>=1.7.4,<2.0.0)", + "python-jose (>=3.5.0,<4.0.0)", + "celery (>=5.6.1,<6.0.0)", + "bcrypt (<4.1)", + "pytest (>=9.0.2,<10.0.0)" ] diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..31bec59 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . app \ No newline at end of file diff --git a/frontend/index.html b/backend/tests/test_auth.py similarity index 100% rename from frontend/index.html rename to backend/tests/test_auth.py diff --git a/frontend/.vite/deps_temp_b0c8c425/package.json b/frontend/.vite/deps_temp_b0c8c425/package.json deleted file mode 100644 index 3dbc1ca..0000000 --- a/frontend/.vite/deps_temp_b0c8c425/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -}