From 15a362ca420e89dd720838cf9410df4c6f4c1e39 Mon Sep 17 00:00:00 2001 From: mguschin Date: Mon, 27 Apr 2026 23:04:50 +0300 Subject: [PATCH] Add EvoSync v3 environment scaffold FastAPI + Celery + Redis + MariaDB stack with 6-service docker-compose. Includes project skeleton (config, database, models, tasks, migrations) and health endpoint with passing test. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 22 +++-- Dockerfile.web | 16 +++ alembic.ini | 38 +++++++ docker-compose.yml | 125 +++++++++++++++++++----- pyproject.toml | 4 + requirements.txt | 29 +++--- tests/__init__.py | 0 tests/conftest.py | 25 +++++ tests/test_health.py | 12 +++ web/__init__.py | 0 web/config.py | 31 ++++++ web/database.py | 24 +++++ web/main.py | 19 ++++ web/migrations/env.py | 50 ++++++++++ web/migrations/script.py.mako | 25 +++++ web/migrations/versions/0001_initial.py | 21 ++++ web/models/__init__.py | 0 web/tasks/__init__.py | 0 web/tasks/celery_app.py | 22 +++++ 19 files changed, 419 insertions(+), 44 deletions(-) create mode 100644 Dockerfile.web create mode 100644 alembic.ini create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_health.py create mode 100644 web/__init__.py create mode 100644 web/config.py create mode 100644 web/database.py create mode 100644 web/main.py create mode 100644 web/migrations/env.py create mode 100644 web/migrations/script.py.mako create mode 100644 web/migrations/versions/0001_initial.py create mode 100644 web/models/__init__.py create mode 100644 web/tasks/__init__.py create mode 100644 web/tasks/celery_app.py diff --git a/.env.example b/.env.example index c87fc25..5535e9c 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,17 @@ -DATABASE_URL=mysql+pymysql://evosync:evosync@db:3306/evosync -SECRET_KEY=your-random-secret-key-here -BASE_URL=http://localhost:8000 - -EVOTOR_APP_ID=your-evotor-app-id -EVOTOR_WEBHOOK_SECRET=your-webhook-secret - -DB_ROOT_PASSWORD=rootpass +# Database +DB_ROOT_PASSWORD=rootpassword DB_NAME=evosync DB_USER=evosync DB_PASSWORD=evosync + +# App +SECRET_KEY=change-me-in-production +BASE_URL=https://evosync.ru + +# Evotor +EVOTOR_APP_ID= +EVOTOR_WEBHOOK_SECRET= + +# Celery Flower +FLOWER_USER=admin +FLOWER_PASSWORD=changeme diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..f1a2ce7 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --upgrade pip && pip install -r requirements.txt + +COPY . . + +CMD ["uvicorn", "web.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..82a7fa6 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = web/migrations +prepend_sys_path = . +version_path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docker-compose.yml b/docker-compose.yml index dd72b5e..08d0d8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,35 +1,110 @@ -version: "3.8" - services: + db: + image: mariadb:11.4 + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + redis: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --save 60 1 --loglevel warning + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + web: build: context: . dockerfile: Dockerfile.web + restart: unless-stopped ports: - - "8080:3000" + - "8080:8000" environment: - - DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:3306/${DB_NAME} - - SECRET_KEY=${SECRET_KEY:-change-me-in-production} - - BASE_URL=${BASE_URL:-https://evosync.ru} - - EVOTOR_APP_ID=${EVOTOR_APP_ID} - - EVOTOR_WEBHOOK_SECRET=${EVOTOR_WEBHOOK_SECRET} - - VK_CLIENT_ID=${VK_CLIENT_ID} - - VK_CLIENT_SECRET=${VK_CLIENT_SECRET} - - JIVOSITE_WIDGET_ID=${JIVOSITE_WIDGET_ID} - - NODE_ENV=production - - VK_DEFAULT_PHOTO_PATH=/app/default_product.png + DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME} + REDIS_URL: redis://redis:6379/0 + SECRET_KEY: ${SECRET_KEY:-change-me-in-production} + BASE_URL: ${BASE_URL:-https://evosync.ru} + EVOTOR_APP_ID: ${EVOTOR_APP_ID:-} + EVOTOR_WEBHOOK_SECRET: ${EVOTOR_WEBHOOK_SECRET:-} + JIVOSITE_WIDGET_ID: ${JIVOSITE_WIDGET_ID:-} + VK_DEFAULT_PHOTO_PATH: /app/default_product.png volumes: - ./5393364294319597854.png:/app/default_product.png:ro - restart: unless-stopped - extra_hosts: - - "host.docker.internal:host-gateway" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + command: > + sh -c "alembic upgrade head && uvicorn web.main:app --host 0.0.0.0 --port 8000" - # sync: - # build: - # context: . - # dockerfile: Dockerfile - # volumes: - # - ./evo:/var/www/evo - # - ./vk:/var/www/vk - # - ./run:/var/www/run - # - ./logs:/var/www/logs + worker: + build: + context: . + dockerfile: Dockerfile.web + restart: unless-stopped + environment: + DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME} + REDIS_URL: redis://redis:6379/0 + SECRET_KEY: ${SECRET_KEY:-change-me-in-production} + EVOTOR_APP_ID: ${EVOTOR_APP_ID:-} + EVOTOR_WEBHOOK_SECRET: ${EVOTOR_WEBHOOK_SECRET:-} + VK_DEFAULT_PHOTO_PATH: /app/default_product.png + volumes: + - ./5393364294319597854.png:/app/default_product.png:ro + depends_on: + redis: + condition: service_healthy + db: + condition: service_healthy + command: celery -A web.tasks.celery_app worker --loglevel=info --concurrency=2 --queues=default,sync,health + + beat: + build: + context: . + dockerfile: Dockerfile.web + restart: unless-stopped + environment: + DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME} + REDIS_URL: redis://redis:6379/0 + SECRET_KEY: ${SECRET_KEY:-change-me-in-production} + depends_on: + redis: + condition: service_healthy + db: + condition: service_healthy + command: celery -A web.tasks.celery_app beat --loglevel=info --scheduler celery.beat:PersistentScheduler --schedule /tmp/celerybeat-schedule + + flower: + build: + context: . + dockerfile: Dockerfile.web + restart: unless-stopped + ports: + - "5555:5555" + environment: + REDIS_URL: redis://redis:6379/0 + FLOWER_BASIC_AUTH: ${FLOWER_USER:-admin}:${FLOWER_PASSWORD:-changeme} + depends_on: + - redis + command: celery -A web.tasks.celery_app flower --port=5555 --basic_auth=${FLOWER_USER:-admin}:${FLOWER_PASSWORD:-changeme} + +volumes: + db_data: + redis_data: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..74cc276 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +addopts = "" diff --git a/requirements.txt b/requirements.txt index 1fd2565..2a4b600 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,20 @@ -fastapi==0.115.0 -uvicorn[standard]==0.30.0 -sqlalchemy==2.0.35 -pymysql==1.1.1 -cryptography>=41.0.0 -jinja2==3.1.4 +fastapi==0.115.5 +uvicorn[standard]==0.32.1 python-multipart==0.0.12 +jinja2==3.1.4 +sqlalchemy==2.0.36 +alembic==1.14.0 +pymysql==1.1.1 +cryptography>=44.0.0 passlib[bcrypt]==1.7.4 -bcrypt==4.2.0 -pydantic-settings==2.5.2 -itsdangerous==2.1.2 -httpx==0.27.2 -alembic==1.13.3 +bcrypt==4.2.1 +pydantic-settings==2.6.1 +httpx==0.28.1 +celery[redis]==5.4.0 +redis==5.2.1 +flower==2.0.1 +python-json-logger==3.2.1 +pytest==8.3.4 +pytest-asyncio==0.24.0 +pytest-cov==6.0.0 +factory-boy==3.3.1 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..24f4572 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from web.database import Base + + +@pytest.fixture(scope="session") +def engine(): + eng = create_engine("sqlite:///:memory:", echo=False) + Base.metadata.create_all(eng) + yield eng + Base.metadata.drop_all(eng) + + +@pytest.fixture +def db_session(engine): + connection = engine.connect() + transaction = connection.begin() + Session = sessionmaker(bind=connection) + session = Session() + yield session + session.close() + transaction.rollback() + connection.close() diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..7b4039c --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,12 @@ +import pytest +from httpx import ASGITransport, AsyncClient + +from web.main import app + + +@pytest.mark.asyncio +async def test_health_returns_ok(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/config.py b/web/config.py new file mode 100644 index 0000000..b5775fa --- /dev/null +++ b/web/config.py @@ -0,0 +1,31 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = "mysql+pymysql://evosync:evosync@db:3306/evosync" + REDIS_URL: str = "redis://redis:6379/0" + SECRET_KEY: str = "change-me-in-production" + BASE_URL: str = "http://localhost:8000" + PASSWORD_RESET_EXPIRE_MINUTES: int = 60 + + EVOTOR_APP_ID: str = "" + EVOTOR_WEBHOOK_SECRET: str = "" + + JIVOSITE_WIDGET_ID: str = "" + VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png" + VK_API_VERSION: str = "5.199" + + CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600 + + FLOWER_USER: str = "admin" + FLOWER_PASSWORD: str = "changeme" + + DB_ROOT_PASSWORD: str = "" + DB_NAME: str = "" + DB_USER: str = "" + DB_PASSWORD: str = "" + + model_config = {"env_file": ".env", "case_sensitive": False, "extra": "ignore"} + + +settings = Settings() diff --git a/web/database.py b/web/database.py new file mode 100644 index 0000000..5e16844 --- /dev/null +++ b/web/database.py @@ -0,0 +1,24 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from web.config import settings + +engine = create_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + pool_recycle=3600, +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/web/main.py b/web/main.py new file mode 100644 index 0000000..b65599e --- /dev/null +++ b/web/main.py @@ -0,0 +1,19 @@ +import logging + +from fastapi import FastAPI + +try: + from pythonjsonlogger import jsonlogger + handler = logging.StreamHandler() + handler.setFormatter(jsonlogger.JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s")) + logging.root.addHandler(handler) +except ImportError: + logging.basicConfig(level=logging.INFO) +logging.root.setLevel(logging.INFO) + +app = FastAPI(title="EvoSync") + + +@app.get("/health") +async def health(): + return {"status": "ok"} diff --git a/web/migrations/env.py b/web/migrations/env.py new file mode 100644 index 0000000..8ce6fdf --- /dev/null +++ b/web/migrations/env.py @@ -0,0 +1,50 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from web.database import Base +import web.models # noqa: F401 — ensure all models are imported before autogenerate + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + from web.config import settings + + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = settings.DATABASE_URL + + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/web/migrations/script.py.mako b/web/migrations/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/web/migrations/script.py.mako @@ -0,0 +1,25 @@ +"""${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: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/web/migrations/versions/0001_initial.py b/web/migrations/versions/0001_initial.py new file mode 100644 index 0000000..019523d --- /dev/null +++ b/web/migrations/versions/0001_initial.py @@ -0,0 +1,21 @@ +"""initial + +Revision ID: 0001 +Revises: +Create Date: 2026-04-27 + +""" +from typing import Sequence, Union + +revision: str = "0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/web/models/__init__.py b/web/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/tasks/__init__.py b/web/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/tasks/celery_app.py b/web/tasks/celery_app.py new file mode 100644 index 0000000..fbd4062 --- /dev/null +++ b/web/tasks/celery_app.py @@ -0,0 +1,22 @@ +from celery import Celery + +from web.config import settings + +celery_app = Celery("evosync", broker=settings.REDIS_URL, backend=settings.REDIS_URL) + +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + timezone="Europe/Moscow", + enable_utc=True, + task_track_started=True, + task_acks_late=True, + worker_prefetch_multiplier=1, + broker_connection_retry_on_startup=True, + task_routes={ + "web.tasks.sync.*": {"queue": "sync"}, + "web.tasks.health.*": {"queue": "health"}, + "web.tasks.catalog.*": {"queue": "default"}, + }, +)