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 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-04-27 23:04:50 +03:00
parent 049e82654d
commit 15a362ca42
19 changed files with 419 additions and 44 deletions

View File

@@ -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

16
Dockerfile.web Normal file
View File

@@ -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"]

38
alembic.ini Normal file
View File

@@ -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

View File

@@ -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:

4
pyproject.toml Normal file
View File

@@ -0,0 +1,4 @@
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = ""

View File

@@ -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

0
tests/__init__.py Normal file
View File

25
tests/conftest.py Normal file
View File

@@ -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()

12
tests/test_health.py Normal file
View File

@@ -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"}

0
web/__init__.py Normal file
View File

31
web/config.py Normal file
View File

@@ -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()

24
web/database.py Normal file
View File

@@ -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()

19
web/main.py Normal file
View File

@@ -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"}

50
web/migrations/env.py Normal file
View File

@@ -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()

View File

@@ -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"}

View File

@@ -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

0
web/models/__init__.py Normal file
View File

0
web/tasks/__init__.py Normal file
View File

22
web/tasks/celery_app.py Normal file
View File

@@ -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"},
},
)