diff --git a/api/depends.py b/api/depends.py
index 5c36f994..2d83c9c4 100644
--- a/api/depends.py
+++ b/api/depends.py
@@ -161,10 +161,14 @@ async def verify_identity_token(
session: AsyncSession = Depends(get_session),
):
"""Проверяет токен из HttpOnly cookie `auth_token`; возвращает Identity."""
+ from database.site_state import mark_site_initialized
+
identity = await _identity_from_cookie(session, request)
if identity is None:
raise HTTPException(status_code=401, detail="Unauthorized")
await bind_identity_actor(request, session, identity)
+ if getattr(identity, "is_admin", False):
+ await mark_site_initialized(session)
return identity
@@ -173,12 +177,15 @@ async def verify_identity_admin(
session: AsyncSession = Depends(get_session),
):
"""Проверяет токен из cookie и что identity.is_admin; для админских ручек v2."""
+ from database.site_state import mark_site_initialized
+
identity = await _identity_from_cookie(session, request)
if identity is None:
raise HTTPException(status_code=401, detail="Unauthorized")
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
await bind_identity_actor(request, session, identity)
+ await mark_site_initialized(session)
return identity
diff --git a/api/v2/routes/auth/password.py b/api/v2/routes/auth/password.py
index 31d37105..b2ce9c0e 100644
--- a/api/v2/routes/auth/password.py
+++ b/api/v2/routes/auth/password.py
@@ -163,6 +163,15 @@ async def login(
except Exception as e:
logger.warning("[Auth] Ошибка rate-limit проверки для email-логина: {}", e)
result = await idb.login_by_email(session, email, body.password)
+ if not result:
+ from database.setup.web_admin_bootstrap import ensure_web_admin
+
+ try:
+ await ensure_web_admin(session)
+ await session.flush()
+ result = await idb.login_by_email(session, email, body.password)
+ except Exception as exc:
+ logger.warning("[Auth] lazy web-admin bootstrap failed: {}", exc)
if not result:
try:
from core.redis_cache import cache_incr, cache_set
@@ -182,6 +191,10 @@ async def login(
pass
identity, token = result
await bind_identity_actor(request, session, identity)
+ if getattr(identity, "is_admin", False):
+ from database.site_state import mark_site_initialized
+
+ await mark_site_initialized(session)
logger.info("[Auth] Login success: identity={}, email={}, ip={}, method=password", identity.id, email, ip)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
@@ -303,6 +316,10 @@ async def login_by_code(
)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity)
+ if getattr(identity, "is_admin", False):
+ from database.site_state import mark_site_initialized
+
+ await mark_site_initialized(session)
logger.info("[Auth] Login success: identity={}, email={}, method=code", identity.id, email_norm)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
diff --git a/api/v2/routes/auth/session.py b/api/v2/routes/auth/session.py
index 34ecc478..b0f40376 100644
--- a/api/v2/routes/auth/session.py
+++ b/api/v2/routes/auth/session.py
@@ -49,6 +49,51 @@ async def logout(
return {"ok": True}
+@router.post("/me/onboarding/complete", response_model=IdentityResponse)
+async def onboarding_complete(
+ session: AsyncSession = Depends(get_session),
+ identity=Depends(verify_identity_token),
+):
+ """Отмечает, что админ прошёл/скипнул онбординг-тур."""
+ from datetime import datetime as _dt
+
+ if identity.onboarding_completed_at is None:
+ identity.onboarding_completed_at = _dt.utcnow()
+ return IdentityResponse.model_validate(identity)
+
+
+@router.post("/me/onboarding/reset", response_model=IdentityResponse)
+async def onboarding_reset(
+ session: AsyncSession = Depends(get_session),
+ identity=Depends(verify_identity_token),
+):
+ """Сбрасывает флаг онбординга — туториал запустится снова."""
+ identity.onboarding_completed_at = None
+ identity.onboarding_stage = "landing"
+ return IdentityResponse.model_validate(identity)
+
+
+_ONBOARDING_STAGES = {"landing", "header", "cabinet", "flow", "elements", "done"}
+
+
+@router.post("/me/onboarding/stage", response_model=IdentityResponse)
+async def onboarding_set_stage(
+ body: dict,
+ session: AsyncSession = Depends(get_session),
+ identity=Depends(verify_identity_token),
+):
+ """Переводит админа на указанный этап онбординга."""
+ from datetime import datetime as _dt
+
+ stage = str(body.get("stage") or "").strip()
+ if stage not in _ONBOARDING_STAGES:
+ raise HTTPException(status_code=400, detail="Неизвестный этап онбординга")
+ identity.onboarding_stage = stage
+ if stage == "done" and identity.onboarding_completed_at is None:
+ identity.onboarding_completed_at = _dt.utcnow()
+ return IdentityResponse.model_validate(identity)
+
+
@router.get("/summary", response_model=AccountSummaryResponse)
async def auth_summary(
request: Request,
diff --git a/api/v2/routes/root.py b/api/v2/routes/root.py
index af2cf1d5..9b360145 100644
--- a/api/v2/routes/root.py
+++ b/api/v2/routes/root.py
@@ -5,7 +5,10 @@ import time
import aiohttp
-from fastapi import APIRouter
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from api.depends import get_session
from config import (
BALANCE_BUTTON,
@@ -79,12 +82,23 @@ async def version():
@router.get("/api/telegram-widget-bot", include_in_schema=True)
async def telegram_widget_bot():
"""Имя бота и имя проекта для веб-клиента."""
+ bot_username = str(USERNAME_BOT or "").replace("@", "").strip()
+ project_name = (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo"
return {
- "bot_username": USERNAME_BOT.replace("@", ""),
- "project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
+ "bot_username": bot_username,
+ "project_name": project_name,
}
+@router.get("/api/site/init-state", include_in_schema=True)
+async def site_init_state(session: AsyncSession = Depends(get_session)):
+ """Прошёл ли сайт первую настройку админом. Используется middleware веб-клиента."""
+ from database.site_state import is_site_initialized
+
+ initialized = await is_site_initialized(session)
+ return {"initialized": bool(initialized)}
+
+
@router.get("/api/site-config", include_in_schema=True)
async def site_config():
"""Настройки витрины и кабинета для веб-клиента (флаги из runtime-конфигов бота)."""
diff --git a/api/v2/schemas/identities.py b/api/v2/schemas/identities.py
index d83d0c8b..57e2138b 100644
--- a/api/v2/schemas/identities.py
+++ b/api/v2/schemas/identities.py
@@ -15,6 +15,8 @@ class IdentityResponse(BaseModel):
is_admin: bool = False
email_verified: bool = False
password_set: bool = False
+ onboarding_completed: bool = False
+ onboarding_stage: str | None = None
created_at: datetime | None
updated_at: datetime | None
diff --git a/core/executor.py b/core/executor.py
index 02cc7f12..91106214 100644
--- a/core/executor.py
+++ b/core/executor.py
@@ -22,12 +22,9 @@ def _atexit_shutdown_pools() -> None:
shutdown_thread_pool()
-class _IgnoreSIGINTProcess(multiprocessing.Process):
- """Процесс, игнорирующий SIGINT в воркере, чтобы Ctrl+C не обрывал queue.get() с трейсбеком."""
-
- def run(self) -> None:
- signal.signal(signal.SIGINT, signal.SIG_IGN)
- super().run()
+def _worker_ignore_sigint() -> None:
+ """Initializer воркера: игнорирует SIGINT, чтобы Ctrl+C не обрывал queue.get() с трейсбеком."""
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
def get_thread_pool() -> ThreadPoolExecutor:
@@ -62,8 +59,11 @@ def get_process_pool() -> ProcessPoolExecutor:
size = max(1, min(int(PROCESS_POOL_SIZE), multiprocessing.cpu_count() or 4))
ctx = multiprocessing.get_context("spawn")
- ctx.Process = _IgnoreSIGINTProcess
- _process_pool = ProcessPoolExecutor(max_workers=size, mp_context=ctx)
+ _process_pool = ProcessPoolExecutor(
+ max_workers=size,
+ mp_context=ctx,
+ initializer=_worker_ignore_sigint,
+ )
atexit.register(_atexit_shutdown_pools)
logger.debug("[Executor] Пул процессов: {} воркеров", size)
return _process_pool
diff --git a/database/migrations/schema_upgrade.py b/database/migrations/schema_upgrade.py
index b6c4a2b0..f372a802 100644
--- a/database/migrations/schema_upgrade.py
+++ b/database/migrations/schema_upgrade.py
@@ -1209,6 +1209,22 @@ async def _migration_v21_add_identity_yandex_sub(conn: AsyncConnection) -> None:
)
+async def _migration_v22_add_identity_onboarding_completed_at(conn: AsyncConnection) -> None:
+ logger.info("[schema_upgrade] v22: identities.onboarding_completed_at")
+ if not await _table_exists(conn, "identities"):
+ return
+ if not await _column_exists(conn, "identities", "onboarding_completed_at"):
+ await _exec_ignore(conn, "ALTER TABLE identities ADD COLUMN onboarding_completed_at TIMESTAMP")
+
+
+async def _migration_v23_add_identity_onboarding_stage(conn: AsyncConnection) -> None:
+ logger.info("[schema_upgrade] v23: identities.onboarding_stage")
+ if not await _table_exists(conn, "identities"):
+ return
+ if not await _column_exists(conn, "identities", "onboarding_stage"):
+ await _exec_ignore(conn, "ALTER TABLE identities ADD COLUMN onboarding_stage VARCHAR(32)")
+
+
_MIGRATIONS = [
(1, "Добавление users.id", _migration_v1_add_users_id),
(2, "Добавление user_id колонок", _migration_v2_add_user_id_columns),
@@ -1231,6 +1247,8 @@ _MIGRATIONS = [
(19, "keys.tg_id nullable, PK на (user_id, client_id)", _migration_v19_keys_tg_id_nullable),
(20, "identities.google_sub", _migration_v20_add_identity_google_sub),
(21, "identities.yandex_sub", _migration_v21_add_identity_yandex_sub),
+ (22, "identities.onboarding_completed_at", _migration_v22_add_identity_onboarding_completed_at),
+ (23, "identities.onboarding_stage", _migration_v23_add_identity_onboarding_stage),
]
diff --git a/database/models/identity.py b/database/models/identity.py
index 9829680d..c6394ff4 100644
--- a/database/models/identity.py
+++ b/database/models/identity.py
@@ -29,5 +29,15 @@ class Identity(DictLikeMixin, Base):
password_hash = Column(String(64), nullable=True)
email_verified = Column(Boolean, nullable=False, server_default=sql_text("false"))
is_admin = Column(Boolean, nullable=False, server_default=sql_text("false"))
+ onboarding_completed_at = Column(DateTime, nullable=True)
+ onboarding_stage = Column(String(32), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+ @property
+ def onboarding_completed(self) -> bool:
+ return self.onboarding_completed_at is not None
+
+ @property
+ def password_set(self) -> bool:
+ return bool(self.password_hash)
diff --git a/database/setup/web_admin_bootstrap.py b/database/setup/web_admin_bootstrap.py
new file mode 100644
index 00000000..e0e8a6f6
--- /dev/null
+++ b/database/setup/web_admin_bootstrap.py
@@ -0,0 +1,47 @@
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from config import WEB_ADMIN_LOGIN, WEB_ADMIN_PASSWORD
+from database.identities import hash_password
+from database.models import Identity
+from logger import logger
+
+
+async def ensure_web_admin(session: AsyncSession) -> None:
+ """Bootstrap/sync web-admin identity from config (WEB_ADMIN_LOGIN/WEB_ADMIN_PASSWORD).
+
+ Вызов на старте API: если креды заданы в config.py — upsert Identity с bcrypt-хешем.
+ Если пусто — no-op с предупреждением, сайт останется без админа.
+ """
+ login = (WEB_ADMIN_LOGIN or "").strip()
+ password = WEB_ADMIN_PASSWORD or ""
+
+ if not (login and password):
+ result = await session.execute(select(Identity).where(Identity.is_admin.is_(True)))
+ has_admin = result.scalars().first() is not None
+ if not has_admin:
+ logger.warning(
+ "[web-admin] Нет web-админа и WEB_ADMIN_LOGIN/WEB_ADMIN_PASSWORD "
+ "не заданы в config.py. Сайт будет недоступен до создания админа."
+ )
+ return
+
+ email = login.lower()
+ result = await session.execute(select(Identity).where(Identity.email == email))
+ identity = result.scalar_one_or_none()
+ password_hash = hash_password(password)
+ if identity is None:
+ identity = Identity(
+ email=email,
+ password_hash=password_hash,
+ is_admin=True,
+ onboarding_stage="landing",
+ )
+ session.add(identity)
+ logger.info("[web-admin] created admin identity {}", email)
+ else:
+ identity.password_hash = password_hash
+ identity.is_admin = True
+ if identity.onboarding_completed_at is None and not identity.onboarding_stage:
+ identity.onboarding_stage = "landing"
+ logger.info("[web-admin] synced password for {}", email)
diff --git a/database/site_state.py b/database/site_state.py
new file mode 100644
index 00000000..75acd253
--- /dev/null
+++ b/database/site_state.py
@@ -0,0 +1,31 @@
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from database.models import Setting
+
+
+_KEY = "SITE_INITIALIZED"
+
+
+async def is_site_initialized(session: AsyncSession) -> bool:
+ result = await session.execute(select(Setting).where(Setting.key == _KEY))
+ setting = result.scalar_one_or_none()
+ return bool(setting and setting.value is True)
+
+
+async def mark_site_initialized(session: AsyncSession) -> None:
+ """Идемпотентно выставляет флаг инициализации сайта."""
+ result = await session.execute(select(Setting).where(Setting.key == _KEY))
+ setting = result.scalar_one_or_none()
+ if setting is None:
+ session.add(Setting(key=_KEY, value=True, description="Сайт прошёл первую настройку админом"))
+ elif setting.value is not True:
+ setting.value = True
+
+
+async def reset_site_initialized(session: AsyncSession) -> None:
+ """Сброс флага — вызывается при полном ресете сайта через TG-бот."""
+ result = await session.execute(select(Setting).where(Setting.key == _KEY))
+ setting = result.scalar_one_or_none()
+ if setting is not None:
+ setting.value = False
diff --git a/handlers/admin/settings/settings_web.py b/handlers/admin/settings/settings_web.py
index 288f3ae9..904c7bf0 100644
--- a/handlers/admin/settings/settings_web.py
+++ b/handlers/admin/settings/settings_web.py
@@ -36,6 +36,12 @@ def build_settings_web_kb() -> InlineKeyboardBuilder:
callback_data=AdminPanelCallback(action="settings_web_url").pack(),
)
)
+ builder.row(
+ InlineKeyboardButton(
+ text="🔄 Сбросить сайт к исходнику",
+ callback_data=AdminPanelCallback(action="settings_web_reset_ask").pack(),
+ )
+ )
builder.row(build_admin_back_btn("settings"))
return builder
@@ -102,6 +108,72 @@ async def prompt_web_url(callback: CallbackQuery, state: FSMContext) -> None:
await callback.answer()
+@router.callback_query(AdminPanelCallback.filter(F.action == "settings_web_reset_ask"))
+async def ask_reset_site(callback: CallbackQuery) -> None:
+ text = (
+ "⚠️ Сброс сайта к исходнику\n\n"
+ "Действие удалит:\n"
+ "• все страницы, блоки, темы и варианты\n"
+ "• всех веб-пользователей (включая админа сайта)\n"
+ "• флаг «сайт проинициализирован»\n\n"
+ "Биллинг-данные (пользователи бота, ключи, платежи) не трогаются.\n\n"
+ "После сброса админ сайта пересоздаётся из переменных окружения "
+ "WEB_ADMIN_LOGIN / WEB_ADMIN_PASSWORD.\n\n"
+ "Действие необратимо."
+ )
+ builder = InlineKeyboardBuilder()
+ builder.row(
+ InlineKeyboardButton(
+ text="❌ Отмена",
+ callback_data=AdminPanelCallback(action="settings_web").pack(),
+ )
+ )
+ builder.row(
+ InlineKeyboardButton(
+ text="⚠️ Да, сбросить сайт",
+ callback_data=AdminPanelCallback(action="settings_web_reset_do").pack(),
+ )
+ )
+ await callback.message.edit_text(text=text, reply_markup=builder.as_markup())
+ await callback.answer()
+
+
+@router.callback_query(AdminPanelCallback.filter(F.action == "settings_web_reset_do"))
+async def do_reset_site(callback: CallbackQuery, session=None) -> None:
+ await callback.answer()
+ await callback.message.edit_text(text="⏳ Сбрасываю сайт...")
+
+ from middlewares.session import release_session_early
+ from services.site_reset import reset_site
+
+ if session is not None:
+ await release_session_early(session)
+
+ try:
+ async with async_session_maker() as s:
+ await reset_site(s)
+ await s.commit()
+ except Exception as exc:
+ from html import escape as html_escape
+
+ safe = html_escape(str(exc))[:2000]
+ await callback.message.edit_text(
+ text=f"❌ Не удалось сбросить сайт\n\n{safe}",
+ reply_markup=build_settings_web_kb().as_markup(),
+ )
+ return
+
+ text = (
+ "✅ Сайт сброшен к исходнику\n\n"
+ "Все веб-страницы, блоки и темы удалены. Админ пересоздан из env.\n"
+ "Откройте сайт и пройдите путь первой установки заново."
+ )
+ await callback.message.edit_text(
+ text=text,
+ reply_markup=build_settings_web_kb().as_markup(),
+ )
+
+
@router.message(WebSettingsState.waiting_for_url)
async def set_web_url(message: Message, state: FSMContext) -> None:
url = message.text.strip() if message.text else ""
diff --git a/main.py b/main.py
old mode 100644
new mode 100755
diff --git a/services/site_reset.py b/services/site_reset.py
new file mode 100644
index 00000000..0b62d7a7
--- /dev/null
+++ b/services/site_reset.py
@@ -0,0 +1,115 @@
+from sqlalchemy import delete, text
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+from config import DATABASE_URL, USE_PGBOUNCER
+from database.models import Identity
+from database.models.web import (
+ WebBlock,
+ WebCustomElementBuild,
+ WebErrorReport,
+ WebFlow,
+ WebFlowEvent,
+ WebNotification,
+ WebPage,
+ WebPageVariant,
+ WebPageVariantBlock,
+ WebPushSubscription,
+ WebTheme,
+)
+from database.site_state import reset_site_initialized
+from logger import logger
+
+
+async def reset_site(_session: AsyncSession) -> None:
+ """Полный сброс web-части: все web_* таблицы + identities.
+
+ Использует отдельный engine без command_timeout — стандартный pool имеет
+ жёсткий 30-сек лимит на запрос, который не переопределяется SET LOCAL
+ (это клиентский asyncpg-таймаут). Для длинных FK-каскадов нужен dedicated
+ коннект с более мягкими настройками.
+ Биллинг-данные (пользователи бота, ключи, платежи) не трогаются.
+ """
+ connect_args: dict = {}
+ db_url = DATABASE_URL
+ if "+asyncpg" in DATABASE_URL:
+ connect_args["command_timeout"] = 300
+ connect_args["timeout"] = 60
+ if USE_PGBOUNCER:
+ connect_args["prepared_statement_cache_size"] = 0
+ sep = "&" if "?" in db_url else "?"
+ db_url = f"{db_url}{sep}prepared_statement_cache_size=0"
+
+ local_engine = create_async_engine(
+ db_url,
+ pool_pre_ping=True,
+ connect_args=connect_args,
+ )
+ local_session_maker = async_sessionmaker(bind=local_engine, expire_on_commit=False, class_=AsyncSession)
+ try:
+ async with local_session_maker() as session:
+ await _run_reset(session)
+ await session.commit()
+ finally:
+ await local_engine.dispose()
+
+
+async def _run_reset(session: AsyncSession) -> None:
+ try:
+ await session.execute(text("SET LOCAL statement_timeout = '5min'"))
+ await session.execute(text("SET LOCAL lock_timeout = '30s'"))
+ except Exception as exc:
+ logger.warning("[site-reset] Не удалось задать timeouts (возможно SQLite): {}", exc)
+
+ steps: list[tuple[str, object]] = [
+ ("web_page_variant_blocks", delete(WebPageVariantBlock)),
+ ("web_blocks", delete(WebBlock)),
+ ("web_page_variants", delete(WebPageVariant)),
+ ("web_themes", delete(WebTheme)),
+ ("web_pages", delete(WebPage)),
+ ("web_push_subscriptions", delete(WebPushSubscription)),
+ ("web_notifications", delete(WebNotification)),
+ ("web_error_reports", delete(WebErrorReport)),
+ ("web_flow_events", delete(WebFlowEvent)),
+ ("web_custom_element_builds", delete(WebCustomElementBuild)),
+ ("web_flows", delete(WebFlow)),
+ ("identities", delete(Identity)),
+ ]
+ for label, stmt in steps:
+ logger.info("[site-reset] step start: {}", label)
+ if label == "identities":
+ try:
+ activity = await session.execute(
+ text(
+ "SELECT pid, state, wait_event_type, wait_event, "
+ "query_start, LEFT(query, 200) AS query "
+ "FROM pg_stat_activity "
+ "WHERE datname = current_database() AND pid <> pg_backend_pid() "
+ "ORDER BY query_start"
+ )
+ )
+ for row in activity.mappings():
+ logger.info("[site-reset] pg_stat pre-identities: {}", dict(row))
+ except Exception as exc:
+ logger.warning("[site-reset] diag pg_stat failed: {}", exc)
+ try:
+ result = await session.execute(stmt)
+ rowcount = getattr(result, "rowcount", "?")
+ logger.info("[site-reset] step ok: {} (rows={})", label, rowcount)
+ except Exception as exc:
+ logger.error("[site-reset] step FAIL: {} — {}: {}", label, type(exc).__name__, exc)
+ try:
+ activity = await session.execute(
+ text(
+ "SELECT pid, state, wait_event_type, wait_event, "
+ "query_start, LEFT(query, 200) AS query "
+ "FROM pg_stat_activity "
+ "WHERE datname = current_database() AND pid <> pg_backend_pid()"
+ )
+ )
+ for row in activity.mappings():
+ logger.error("[site-reset] pg_stat post-fail: {}", dict(row))
+ except Exception:
+ pass
+ raise
+ await reset_site_initialized(session)
+ logger.info("[site-reset] Веб-часть сайта сброшена к исходному состоянию")