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] Веб-часть сайта сброшена к исходному состоянию")