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:
22
.env.example
22
.env.example
@@ -1,11 +1,17 @@
|
|||||||
DATABASE_URL=mysql+pymysql://evosync:evosync@db:3306/evosync
|
# Database
|
||||||
SECRET_KEY=your-random-secret-key-here
|
DB_ROOT_PASSWORD=rootpassword
|
||||||
BASE_URL=http://localhost:8000
|
|
||||||
|
|
||||||
EVOTOR_APP_ID=your-evotor-app-id
|
|
||||||
EVOTOR_WEBHOOK_SECRET=your-webhook-secret
|
|
||||||
|
|
||||||
DB_ROOT_PASSWORD=rootpass
|
|
||||||
DB_NAME=evosync
|
DB_NAME=evosync
|
||||||
DB_USER=evosync
|
DB_USER=evosync
|
||||||
DB_PASSWORD=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
16
Dockerfile.web
Normal 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
38
alembic.ini
Normal 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
|
||||||
@@ -1,35 +1,110 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
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:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.web
|
dockerfile: Dockerfile.web
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:3000"
|
- "8080:8000"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:3306/${DB_NAME}
|
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
|
||||||
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
REDIS_URL: redis://redis:6379/0
|
||||||
- BASE_URL=${BASE_URL:-https://evosync.ru}
|
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||||
- EVOTOR_APP_ID=${EVOTOR_APP_ID}
|
BASE_URL: ${BASE_URL:-https://evosync.ru}
|
||||||
- EVOTOR_WEBHOOK_SECRET=${EVOTOR_WEBHOOK_SECRET}
|
EVOTOR_APP_ID: ${EVOTOR_APP_ID:-}
|
||||||
- VK_CLIENT_ID=${VK_CLIENT_ID}
|
EVOTOR_WEBHOOK_SECRET: ${EVOTOR_WEBHOOK_SECRET:-}
|
||||||
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
|
JIVOSITE_WIDGET_ID: ${JIVOSITE_WIDGET_ID:-}
|
||||||
- JIVOSITE_WIDGET_ID=${JIVOSITE_WIDGET_ID}
|
VK_DEFAULT_PHOTO_PATH: /app/default_product.png
|
||||||
- NODE_ENV=production
|
|
||||||
- VK_DEFAULT_PHOTO_PATH=/app/default_product.png
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./5393364294319597854.png:/app/default_product.png:ro
|
- ./5393364294319597854.png:/app/default_product.png:ro
|
||||||
restart: unless-stopped
|
depends_on:
|
||||||
extra_hosts:
|
db:
|
||||||
- "host.docker.internal:host-gateway"
|
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:
|
worker:
|
||||||
# build:
|
build:
|
||||||
# context: .
|
context: .
|
||||||
# dockerfile: Dockerfile
|
dockerfile: Dockerfile.web
|
||||||
# volumes:
|
restart: unless-stopped
|
||||||
# - ./evo:/var/www/evo
|
environment:
|
||||||
# - ./vk:/var/www/vk
|
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
|
||||||
# - ./run:/var/www/run
|
REDIS_URL: redis://redis:6379/0
|
||||||
# - ./logs:/var/www/logs
|
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
4
pyproject.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = ""
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
fastapi==0.115.0
|
fastapi==0.115.5
|
||||||
uvicorn[standard]==0.30.0
|
uvicorn[standard]==0.32.1
|
||||||
sqlalchemy==2.0.35
|
|
||||||
pymysql==1.1.1
|
|
||||||
cryptography>=41.0.0
|
|
||||||
jinja2==3.1.4
|
|
||||||
python-multipart==0.0.12
|
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
|
passlib[bcrypt]==1.7.4
|
||||||
bcrypt==4.2.0
|
bcrypt==4.2.1
|
||||||
pydantic-settings==2.5.2
|
pydantic-settings==2.6.1
|
||||||
itsdangerous==2.1.2
|
httpx==0.28.1
|
||||||
httpx==0.27.2
|
celery[redis]==5.4.0
|
||||||
alembic==1.13.3
|
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
0
tests/__init__.py
Normal file
25
tests/conftest.py
Normal file
25
tests/conftest.py
Normal 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
12
tests/test_health.py
Normal 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
0
web/__init__.py
Normal file
31
web/config.py
Normal file
31
web/config.py
Normal 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
24
web/database.py
Normal 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
19
web/main.py
Normal 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
50
web/migrations/env.py
Normal 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()
|
||||||
25
web/migrations/script.py.mako
Normal file
25
web/migrations/script.py.mako
Normal 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"}
|
||||||
21
web/migrations/versions/0001_initial.py
Normal file
21
web/migrations/versions/0001_initial.py
Normal 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
0
web/models/__init__.py
Normal file
0
web/tasks/__init__.py
Normal file
0
web/tasks/__init__.py
Normal file
22
web/tasks/celery_app.py
Normal file
22
web/tasks/celery_app.py
Normal 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"},
|
||||||
|
},
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user