mirror of
https://github.com/lorsanstand/Aether.git
synced 2026-06-19 12:05:16 +03:00
Create authorization system
This commit is contained in:
Executable
+147
@@ -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 <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
Executable
+62
@@ -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()
|
||||
Executable
+9
@@ -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",
|
||||
}
|
||||
Executable
+133
@@ -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()
|
||||
Executable
+27
@@ -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)
|
||||
@@ -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")
|
||||
Executable
+38
@@ -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)
|
||||
Regular → Executable
+75
-4
@@ -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)
|
||||
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)
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
Generic single-database configuration with an async dbapi.
|
||||
Executable
+93
@@ -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()
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
from .email_tasks import *
|
||||
@@ -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)
|
||||
@@ -0,0 +1,163 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Подтверждение email — {{company_name}}</title>
|
||||
<style>
|
||||
/* Подключаем шрифты (Lora для заголовков, Inter для текста) */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Lora:ital,wght@0,600;1,600&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: 'Inter', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #2C2C2C; /* Антрацит из концепта */
|
||||
background-color: #F5F5F1; /* Слоновая кость */
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.email-wrapper {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 16px; /* Мягкие скругления */
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(107, 112, 92, 0.08); /* Легкая оливковая тень */
|
||||
}
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
padding: 40px 30px 20px;
|
||||
background-color: #EBEBE6; /* Песочный серый для разделения */
|
||||
}
|
||||
.logo-text {
|
||||
font-family: 'Lora', serif;
|
||||
font-size: 24px;
|
||||
letter-spacing: 3px;
|
||||
color: #6B705C; /* Оливковый акцент */
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.email-body {
|
||||
padding: 40px 50px;
|
||||
background: #ffffff;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Lora', serif;
|
||||
font-size: 26px;
|
||||
color: #2C2C2C;
|
||||
margin-bottom: 24px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
p {
|
||||
color: #555555;
|
||||
margin-bottom: 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.username {
|
||||
color: #D27D56; /* Терракотовый акцент */
|
||||
font-weight: 600;
|
||||
}
|
||||
.button-container {
|
||||
text-align: center;
|
||||
margin: 40px 0;
|
||||
}
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
padding: 18px 44px;
|
||||
background-color: #D27D56; /* Терракотовый */
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
border-radius: 30px; /* Очень круглые кнопки */
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
.divider {
|
||||
margin: 40px auto;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: #EBEBE6;
|
||||
}
|
||||
.link-box {
|
||||
background-color: #F9F9F7;
|
||||
border: 1px dashed #6B705C; /* Оливковая пунктирная рамка */
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.link-label {
|
||||
font-size: 12px;
|
||||
color: #6B705C;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.link-text {
|
||||
color: #D27D56;
|
||||
word-break: break-all;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.info-box {
|
||||
text-align: center;
|
||||
color: #888888;
|
||||
font-size: 13px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.footer {
|
||||
padding: 40px 50px;
|
||||
text-align: center;
|
||||
background-color: #F9F9F7;
|
||||
color: #8B8B8B;
|
||||
font-size: 13px;
|
||||
}
|
||||
.footer-brand {
|
||||
font-family: 'Lora', serif;
|
||||
color: #6B705C;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-wrapper">
|
||||
<div class="logo-section">
|
||||
<div class="logo-text">{{company_name}}</div>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
<h1>Подтверждение почты</h1>
|
||||
<p>Здравствуйте, <span class="username">{{username}}</span>!</p>
|
||||
<p>Мы рады приветствовать вас в <b>{{company_name}}</b> — пространстве для спокойного общения. Чтобы начать путь, пожалуйста, подтвердите ваш электронный адрес.</p>
|
||||
|
||||
<div class="button-container">
|
||||
<a href="{{url}}" class="cta-button">Активировать аккаунт</a>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
Ссылка будет активна в течение {{expire_minutes}} минут.
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="link-box">
|
||||
<div class="link-label">Или скопируйте ссылку в браузер:</div>
|
||||
<div class="link-text">{{url}}</div>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #999; margin-top: 30px; text-align: center; font-style: italic;">
|
||||
Если вы не регистрировались в {{company_name}}, просто проигнорируйте это письмо.
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-brand">{{company_name}}</div>
|
||||
<div>Сделано с заботой о вашем спокойствии</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
Здравствуйте, {{username}}!
|
||||
|
||||
Спасибо за регистрацию!
|
||||
|
||||
Для подтверждения вашего email адреса перейдите по ссылке:
|
||||
{{url}}
|
||||
|
||||
Если вы не создавали аккаунт на нашем сайте, просто проигнорируйте это письмо.
|
||||
Внимание: Эта ссылка действительна в течение {{expire_minutes}} минут
|
||||
|
||||
С уважением,
|
||||
Команда {{company_name}}
|
||||
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Восстановление пароля — {{company_name}}</title>
|
||||
<style>
|
||||
/* Подключаем шрифты концепта Natural Sand */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Lora:ital,wght@0,600;1,600&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: 'Inter', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #2C2C2C; /* Антрацит */
|
||||
background-color: #F5F5F1; /* Слоновая кость */
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.email-wrapper {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(107, 112, 92, 0.08); /* Оливковая тень */
|
||||
}
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
padding: 40px 30px 20px;
|
||||
background-color: #EBEBE6; /* Песочный серый */
|
||||
}
|
||||
.logo-text {
|
||||
font-family: 'Lora', serif;
|
||||
font-size: 24px;
|
||||
letter-spacing: 3px;
|
||||
color: #6B705C; /* Оливковый */
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.email-body {
|
||||
padding: 40px 50px;
|
||||
background: #ffffff;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Lora', serif;
|
||||
font-size: 26px;
|
||||
color: #2C2C2C;
|
||||
margin-bottom: 24px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
p {
|
||||
color: #555555;
|
||||
margin-bottom: 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.username {
|
||||
color: #D27D56; /* Терракотовый */
|
||||
font-weight: 600;
|
||||
}
|
||||
.button-container {
|
||||
text-align: center;
|
||||
margin: 40px 0;
|
||||
}
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
padding: 18px 44px;
|
||||
background-color: #6B705C; /* Оливковый для безопасности/спокойствия */
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
border-radius: 30px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
.divider {
|
||||
margin: 40px auto;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: #EBEBE6;
|
||||
}
|
||||
.link-box {
|
||||
background-color: #F9F9F7;
|
||||
border: 1px dashed #6B705C;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.link-label {
|
||||
font-size: 12px;
|
||||
color: #6B705C;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.link-text {
|
||||
color: #D27D56;
|
||||
word-break: break-all;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.warning-box {
|
||||
background-color: #FEF9F6; /* Едва заметный терракотовый фон */
|
||||
border-left: 3px solid #D27D56;
|
||||
padding: 16px 20px;
|
||||
margin: 25px 0;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
}
|
||||
.security-notice {
|
||||
background-color: #F9F9F7;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-top: 30px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #EBEBE6;
|
||||
}
|
||||
.security-notice-title {
|
||||
font-family: 'Lora', serif;
|
||||
font-weight: 600;
|
||||
color: #6B705C;
|
||||
margin-bottom: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.footer {
|
||||
padding: 40px 50px;
|
||||
text-align: center;
|
||||
background-color: #F9F9F7;
|
||||
color: #8B8B8B;
|
||||
font-size: 13px;
|
||||
}
|
||||
.footer-brand {
|
||||
font-family: 'Lora', serif;
|
||||
color: #6B705C;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-wrapper">
|
||||
<div class="logo-section">
|
||||
<div class="logo-text">{{company_name}}</div>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
<h1>Сброс пароля</h1>
|
||||
<p>Здравствуйте, <span class="username">{{username}}</span>.</p>
|
||||
<p>Мы получили запрос на восстановление доступа к вашему аккаунту в {{company_name}}. Если это сделали вы, нажмите на кнопку ниже.</p>
|
||||
|
||||
<div class="button-container">
|
||||
<a href="{{url}}" class="cta-button">Установить новый пароль</a>
|
||||
</div>
|
||||
|
||||
<div class="warning-box">
|
||||
⏱ <b>Внимание:</b> Эта ссылка будет активна в течение {{expire_minutes}} минут.
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="link-box">
|
||||
<div class="link-label">Запасная ссылка:</div>
|
||||
<div class="link-text">{{url}}</div>
|
||||
</div>
|
||||
|
||||
<div class="security-notice">
|
||||
<div class="security-notice-title">🛡 Забота о безопасности</div>
|
||||
<p style="margin: 0; font-size: 14px;">Если вы не запрашивали сброс пароля, просто проигнорируйте это сообщение. Ваш текущий пароль остается в безопасности.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-brand">{{company_name}}</div>
|
||||
<div>Ваша безопасность — часть нашего эфира</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
Здравствуйте, {{username}}!
|
||||
|
||||
Мы получили запрос на сброс пароля для вашей учетной записи.
|
||||
|
||||
Для создания нового пароля перейдите по ссылке:
|
||||
{{url}}
|
||||
|
||||
ВНИМАНИЕ: Эта ссылка действительна в течение {{expire_minutes}} минут.
|
||||
|
||||
Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо. Ваш пароль останется без изменений.
|
||||
|
||||
С уважением,
|
||||
Команда {{company_name}}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Executable
+30
@@ -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"))
|
||||
Executable
+80
@@ -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"}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Executable
+27
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Executable
+22
@@ -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
|
||||
Executable
+73
@@ -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:
|
||||
+626
-1
@@ -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"
|
||||
|
||||
Regular → Executable
+11
-1
@@ -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)"
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
pythonpath = . app
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
Reference in New Issue
Block a user