Admin onboarding + BrickGrid + workflow author fix

This commit is contained in:
Vladless
2026-04-17 10:45:30 +00:00
parent 11b5a8f4bd
commit 4cb84ceacc
13 changed files with 389 additions and 11 deletions
+7
View File
@@ -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
+17
View File
@@ -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)
+45
View File
@@ -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,
+17 -3
View File
@@ -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-конфигов бота)."""
+2
View File
@@ -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
+8 -8
View File
@@ -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
+18
View File
@@ -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),
]
+10
View File
@@ -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)
+47
View File
@@ -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)
+31
View File
@@ -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
+72
View File
@@ -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 = (
"<b>⚠️ Сброс сайта к исходнику</b>\n\n"
"Действие удалит:\n"
"• все страницы, блоки, темы и варианты\n"
"• всех веб-пользователей (включая админа сайта)\n"
"• флаг «сайт проинициализирован»\n\n"
"Биллинг-данные (пользователи бота, ключи, платежи) не трогаются.\n\n"
"После сброса админ сайта пересоздаётся из переменных окружения "
"<code>WEB_ADMIN_LOGIN</code> / <code>WEB_ADMIN_PASSWORD</code>.\n\n"
"<b>Действие необратимо.</b>"
)
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="<b>⏳ Сбрасываю сайт...</b>")
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"<b>❌ Не удалось сбросить сайт</b>\n\n<code>{safe}</code>",
reply_markup=build_settings_web_kb().as_markup(),
)
return
text = (
"<b>✅ Сайт сброшен к исходнику</b>\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 ""
Regular → Executable
View File
+115
View File
@@ -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] Веб-часть сайта сброшена к исходному состоянию")